macOS
Cleanest desktop, real symlinks, Seatbelt sandbox, brew. The path of least resistance if you can choose.
Claude Code runs the same way on every OS — until it doesn't. This page is the catalogue of the places it doesn't: paths, line endings, junctions vs symlinks, sandboxes, schedulers, antivirus, default shells, and the one-config-fits-all tricks that mostly hold up across Windows, macOS, Linux, and WSL.
Pair with /settings/terminals for the shell-specific layer beneath this.
| Behaviour | Windows | macOS | Linux | WSL |
|---|---|---|---|---|
| Config home | C:\Users\<u>\.claude\ | ~/.claude/ | ~/.claude/ | /home/<u>/.claude/ |
| Default shell | PowerShell 7 | zsh (since Catalina) | bash (distro default) | bash (or whatever the WSL distro ships) |
| Default install | npm i -g @anthropic-ai/claude-code | brew install claude-code | npm i -g … or distro pkg | Same as Linux distro |
| Sandbox | Path-based only (no kernel sandbox) | Seatbelt (sandbox-exec) | Landlock + seccomp | Linux sandbox applies |
| Symlinks need admin? | Yes by default (or Developer Mode) | No | No | No |
| Symlink alternative | NTFS junction (mklink /J) | — | — | — |
| Line endings | CRLF (mostly) | LF | LF | LF |
| Case-sensitive FS | No (NTFS default) | No (APFS default) | Yes (ext4 etc.) | Yes (in WSL) |
| Scheduler | Task Scheduler / schtasks | launchd / launchctl | cron / systemd timers | Linux cron (only fires when WSL is running) |
| Antivirus interferes? | Defender scans some hooks | Rare (Gatekeeper signs) | Rare | Rare (Defender doesn't read into WSL by default) |
settings.json, CLAUDE.md, commands/, agents/, and skills/ are all identical files. The differences are in how the surrounding environment treats them. Sync the files via git; deal with the environment differences once per OS.
PowerShell 7 (sometimes called pwsh) is the modern Windows shell Claude Code defaults to in interactive use, not the older powershell.exe (5.1) or cmd.exe. The bash tool in Claude Code calls out to Git Bash if it's installed, otherwise WSL bash, otherwise falls back to PowerShell — keep an eye on which one your hooks expect.
\\ (it's JSON).mklink /J) don't and work for directories. Use junctions when you'd use a symlink to a folder elsewhere.HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled = 1 if you have deep node_modules chains.~/.claude/ from Defender's real-time scan if you're seeing 200ms hook delays.# open WSL bash for a single Claude command from PowerShell PS> wsl bash -c "claude --print 'summarise CHANGELOG.md'" # junction (preferred over symlink for directories) PS> mklink /J "$env:USERPROFILE\.claude\projects\foo\memory" "$env:USERPROFILE\OneDrive\claude-memory\foo" # run a scheduled Claude task daily at 09:00 PS> schtasks /create /tn "Claude dotfiles pull" /tr "git -C C:\Users\walhu\.claude pull --ff-only" /sc daily /st 09:00
NUL trap: bash 2>/dev/null doesn't translate to PowerShell 2>$null. If you write hooks in JSON and want them portable, prefer 2>NUL (which works in both via cmd-shell semantics) or branch on $IsWindows in PowerShell hooks.
The cleanest desktop for Claude Code: real symlinks, a kernel-level sandbox (Seatbelt), zsh as the default shell, and Homebrew as a coherent package manager. Most things "just work."
sandbox-exec with a deny-write profile outside the workspace. Quite strict; very fast; mostly invisible. When you see "operation not permitted" it's usually Seatbelt.brew install claude-code.auth.json. security add-generic-password -s "anthropic" -a "$USER" -w "$ANTHROPIC_API_KEY"; then apiKeyHelper in settings.json reads it back.bash -c. zsh's setopt SH_WORD_SPLIT defaults differ enough to bite./opt/homebrew/ on Apple Silicon and /usr/local/ on Intel. $(brew --prefix) in scripts; don't hardcode either.# run a daily Claude job via launchd (preferred over cron on Mac) $ cat > ~/Library/LaunchAgents/com.walhus.claude-pull.plist <<'XML' <?xml version="1.0" encoding="UTF-8"?> <plist><dict> <key>Label</key><string>com.walhus.claude-pull</string> <key>ProgramArguments</key><array> <string>/usr/bin/git</string> <string>-C</string><string>/Users/walhu/.claude</string> <string>pull</string><string>--ff-only</string> </array> <key>StartCalendarInterval</key><dict><key>Hour</key><integer>9</integer></dict> </dict></plist> XML $ launchctl load ~/Library/LaunchAgents/com.walhus.claude-pull.plist
The native habitat for Claude's Bash tool — and the OS your droplet, NAS, and most VPSes run. Differences across distros (Ubuntu, Debian, Fedora, Arch, Alpine) are mostly about package managers; the rest is the same.
systemctl reload nginx won't work on Alpine — service nginx reload instead.claude login needs the --no-browser flag or an API key via ANTHROPIC_API_KEY. Almost always API key on a server.audit2allow + a custom policy module if it bites; or setenforce 0 for the session if you're just troubleshooting.# cron entry for nightly Claude task on the droplet $ crontab -e 0 3 * * * /usr/local/bin/claude --print "scan today's /var/log/nginx/error.log for unusual patterns" > /var/log/claude-nightly.md 2>&1 # systemd timer (preferred over cron on modern distros) $ systemctl --user edit --force --full claude-pull.service # [Service] # ExecStart=/usr/bin/git -C %h/.claude pull --ff-only $ systemctl --user edit --force --full claude-pull.timer # [Timer] # OnCalendar=daily # Persistent=true
Windows Subsystem for Linux (WSL2) is a real Linux kernel running alongside Windows. Run Claude Code inside it and you get the Linux sandbox, Linux paths, Linux tools — but you can still drag files in from Windows Explorer. For mixed-OS fleets it's often the cleanest answer to "do I want one Windows config or one Linux config?"
/home/<u>/; Windows is mounted at /mnt/c/. Files in /home/ are fast; files under /mnt/c/ are slow (Windows filesystem behind a translation layer).~/.claude/ inside WSL, not on /mnt/c/. The performance difference for read-heavy ops (which Claude does constantly) is 10×.cmd.exe /c <tool>, or invoke a WSL binary from Windows PowerShell with wsl <cmd>. Both are fine; just don't expect npm to be the same binary on both sides.localhost:port (since WSL2 update). Reverse direction sometimes needs explicit --listen 0.0.0.0.\\wsl$\ by default, but if you accidentally store sensitive things in /mnt/c/... Defender will scan them. Keep secrets inside WSL.Many WholeTech-style operators run Claude Code inside WSL on their Windows boxes — they get the Linux sandbox and the parity with the droplet. Windows is the OS layer; WSL is the development environment. The Windows-side ~/.claude/ exists but is largely unused; the WSL-side one is where the config lives.
wsl bash -lc "<your command>". The -l loads your bash profile so your aliases work. Useful inside Windows scheduled tasks that want to fire a Linux Claude job.
Hardcoded paths in settings.json are the most common cross-OS friction. Three coping strategies, in order of preference:
${env:HOME} / $env:USERPROFILE"mcpServers": { "fs-home": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "${env:HOME}/projects"] } }
Claude Code expands env vars in settings.json. HOME works on macOS/Linux/WSL; on Windows, USERPROFILE is the equivalent. JSON config rarely needs to know the OS — just the variable.
Windows accepts forward slashes in most contexts (PowerShell, Node, Python, Go). So C:/Users/walhu/websites works in JSON as a path on Windows even though convention is backslashes. Saves you from \\ JSON-escape hell.
Some keys really do differ per OS. Keep a base settings.json in git and a per-OS overlay settings.<os>.json applied during bootstrap.
# in your bootstrap script case "$(uname)" in Darwin) cp settings.macos.json ~/.claude/settings.json ;; Linux) cp settings.linux.json ~/.claude/settings.json ;; esac
The classic cross-OS gotcha. JSON parsers tolerate both. Shell scripts in hooks do not — a CRLF-terminated bash script throws a confusing "command not found" because the interpreter's name has an invisible \r on the end.
# in ~/.claude/.gitattributes
*.sh text eol=lf
*.json text eol=lf
*.md text eol=lf
*.ps1 text eol=crlf
hooks/** text eol=lf
This forces hook scripts to LF regardless of OS. PowerShell scripts (which you'll only ever run on Windows) can stay CRLF.
'\r': command not found or : command not found with an invisible character. CRLF in the script. Fix the .gitattributes, re-checkout the files.
| Link type | OS | Needs admin? | Best for |
|---|---|---|---|
ln -s | macOS, Linux, WSL | No | Files and directories. The default. |
mklink /D | Windows symlink (directory) | Yes, or Developer Mode | When you specifically want a soft link. |
mklink (file) | Windows symlink (file) | Yes, or Developer Mode | Single-file links. |
mklink /J | Windows NTFS junction | No | Use this for directories on Windows. Same-volume only. |
mklink /H | Windows hard link | No | Hard link for files (rarely useful here). |
# macOS / Linux / WSL $ ln -s ~/OneDrive/claude-memory ~/.claude/projects/<slug>/memory # Windows PowerShell PS> mklink /J "$env:USERPROFILE\.claude\projects\<slug>\memory" "$env:USERPROFILE\OneDrive\claude-memory\<slug>"
Junctions are good enough for almost every use here — they work without admin, behave like a directory, and survive a reboot. Reserve real Windows symlinks for the rare case where you need cross-volume linking.
| OS | Sandbox | Antivirus concerns |
|---|---|---|
| Windows | Path-based only (no kernel sandbox). | Defender real-time scan can slow hooks. Add ~/.claude/ as an exclusion if you see 100ms+ hook delays. |
| macOS | Seatbelt via sandbox-exec — kernel-level, fast, denies writes outside workspace. | Mostly invisible. Gatekeeper signs the install; don't sideload from elsewhere. |
| Linux | Landlock + seccomp — kernel-level, strictest. | ClamAV (if you run it) is fine. SELinux/AppArmor occasionally interfere on Fedora/Ubuntu hardened. |
| WSL | Linux sandbox applies inside WSL. | Defender doesn't scan into WSL by default. |
Practical: if a hook works locally on one machine and dies on another with a permission error, sandbox or AV is the first suspect. Run the hook command directly in a shell on the failing machine — if it works there but fails through Claude, sandbox; if it fails everywhere, real permission issue.
The Bash(...) permission pattern matches against the full command string Claude runs. The string differs between shells, so an allowlist entry that works on Mac/Linux may not match on Windows.
| Command intent | macOS / Linux / WSL | Windows (PowerShell) |
|---|---|---|
| git status | Bash(git status:*) | Bash(git status:*) |
| cURL a URL | Bash(curl:*) | Bash(curl.exe:*) |
| DNS lookup | Bash(dig:*) / Bash(host:*) | Bash(nslookup:*) / Bash(Resolve-DnsName:*) |
| Remove a file (safe) | Bash(rm:*) | Bash(Remove-Item:*) |
| List files | Bash(ls:*) | Bash(Get-ChildItem:*) / Bash(ls:*) (PowerShell aliases ls) |
For a cross-OS allowlist, list both forms. The allow list is OR-matched — having extra entries doesn't hurt.
"permissions": { "allow": [ "Bash(curl:*)", // macOS/Linux "Bash(curl.exe:*)", // Windows "Bash(dig:*)", "Bash(nslookup:*)" ] }
macOS APFS and Windows NTFS are case-preserving but case-insensitive. Linux ext4 is both. A file named README.md on Mac is also reachable as readme.md; on Linux it isn't.
commands/Foo.md on Mac becomes /foo as a slash command. Same file on Linux pulled from the same git repo behaves the same. Same file imported with a different case on Mac looks fine but produces git-detected case-only renames that Linux machines can't resolve.Pick a case convention and stick to it — lowercase for everything is the safe default. Add core.ignoreCase = false in your repo so git surfaces case-only renames as conflicts you must resolve, not silently merges them.
PS> schtasks /create /tn "Claude pull" /tr "git -C C:\Users\walhu\.claude pull --ff-only" /sc daily /st 09:00
See the macOS section for a full .plist example. launchctl load to activate; launchctl unload to disable; launchctl list | grep claude to inspect.
# cron $ crontab -e 0 9 * * * /usr/bin/git -C $HOME/.claude pull --ff-only # systemd timer (better, has logs) $ systemctl --user enable --now claude-pull.timer
Cron inside WSL only runs while WSL is running. If WSL isn't always on, schedule from the Windows side instead — a Windows scheduled task that invokes wsl bash -lc "...".
If you're starting fresh or consolidating, here's a cheat sheet for which OS to centre your Claude work on, by who you are.
Cleanest desktop, real symlinks, Seatbelt sandbox, brew. The path of least resistance if you can choose.
Run Claude in WSL Ubuntu. Get the Linux sandbox, the parity with your droplet, and keep Windows for the desktop apps you need.
If your work is mostly server-side (droplet config, CI, deploys), running Claude on Linux means your local environment matches production.
The fleet doesn't need to be uniform. Plenty of WholeTech-shaped setups run macOS at home, Windows at the shop, Linux on the droplet, and WSL on the work laptop — and they all sync the same ~/.claude/ via git. The matrix above is how you keep them from stepping on each other.