Showing posts with label Claude. Show all posts
Showing posts with label Claude. Show all posts

30 May 2026

Writing a Claude Code FreeBSD manager script

This post is about claude-freebsd, a shell script that installs and manages Claude Code on FreeBSD via the Linux compatibility layer. It started as a frustration, became a debugging rabbit hole, and ended up being a genuinely fun afternoon project — one I used an LLM to help build, which felt appropriately meta.

Between ADHD and the kind of 4AM coding marathon this turned into, the conversation log has a more complete picture of what we actually tried than I do — so I asked that same LLM to help draft this post.

How It Started

I'd been using Claude Code under FreeBSD via the npm package for a while. That worked fine — it was pure JavaScript running under native FreeBSD Node.js, no fuss. Then I wanted a newer version. I installed the updated package, ran claude, and got nothing. No output, no error. Just a hanging process.

My first assumption was that this was some Linuxulator kernel compatibility issue I was going to need to dig into. That assumption was technically correct, in the same way that "my car won't start" and "the fuel cap isn't on properly" are technically related.

More on that in a moment.

In the meantime, I learned something important about what had changed: from version 2.1.113 onward, Anthropic stopped shipping runnable JavaScript in the npm package. It now downloads a per-platform compiled native binary at install time. The supported platforms are linux-x64, linux-arm64, darwin-x64, and darwin-arm64. FreeBSD is not on the list.

The binary is built with bun build --compile. Strictly speaking, Anthropic don't need Bun in the FreeBSD ports tree to ship a FreeBSD binary — they'd use a cross-compile target rather than building natively on FreeBSD. But that still means build pipeline work, CI resource allocation, and ongoing maintenance of a new platform target. Having worked in both small and large engineering teams, I completely understand why that gets prioritised behind other things — especially when FreeBSD may represent a relatively small slice of the user base. There's an open issue tracking the situation, including an official comment from Anthropic pointing FreeBSD users toward the Linuxulator as the interim approach.

And that approach actually works. The linux-x64 binary runs fine under FreeBSD's Linuxulator. So the solution is to fetch that binary, give it the environment it needs, and get out of the way.

Since I do most of my actual Claude Code work from a WSL2 environment anyway, I used that to write the script itself — which meant I was using Claude Code to write a tool to make Claude Code work on FreeBSD. The real payoff is having Claude working directly on my FreeBSD servers now for live checks, which is what I actually wanted from the start.

The Hanging Problem

Once I understood the binary situation, I wrote a quick prototype: fetch the Linux binary from downloads.claude.ai, install it, run it. Non-interactive use worked straight away. claude --version, claude --print "say hello" — fine. The full interactive TUI: nothing. Same silent hang I'd started with.

At this point I reached for truss, FreeBSD's syscall tracer. This turned out to be several hours well spent, in the sense that it was genuinely interesting and completely failed to identify the real problem.

The trace showed the process spinning on futex(FUTEX_WAIT), with occasional statx calls returning ENOENT for paths under ~/.claude/. I went down the path of Linuxulator futex compatibility — trying LD_PRELOAD interceptors for futex and epoll, various environment variables, pinning to older binary versions. None of it made any difference. The binary uses direct syscalls rather than glibc wrappers, so LD_PRELOAD approaches were never going to work anyway. Claude Code and I went fairly deep into this together and both arrived at the wrong conclusion — proof that human and AI engineers are equally capable of confidently barking up the wrong tree.

The actual problem: /home wasn't bind-mounted into the Linuxulator environment.

When Linux binaries run under the Linuxulator, they see the world through /compat/linux/. If they try to access /home/rick/.claude/, they look in /compat/linux/home/rick/.claude/. For that path to exist, you need a nullfs bind mount of /home at /compat/linux/home. My /etc/fstab had entries for this, but they had failok set — and on a ZFS root system, they were silently failing. The directories existed but were empty. The binary couldn't find its config, couldn't write temp files, and its startup initialisation never completed.

I found this entirely by accident. I'd been trying to set up a Linux chroot jail as another angle of attack, created the directories manually as part of that process, then remounted. Claude launched immediately. It took a moment to connect the dots, but once I did, the fix was obvious and the solution was already in place.

There's a second mount issue that's even more subtle: fdescfs, which provides the file descriptor filesystem at /compat/linux/dev/fd, must be mounted with the linrdlnk option. Without it, Claude Code also hangs at startup. This one is particularly awkward because mount(8) doesn't display fdescfs options in its output — you can see the filesystem is mounted, see nothing obviously wrong, and still have Claude hang. The only reliable check is to read /etc/fstab directly, which is what the wrapper does.

The complete working fstab entries:

devfs     /compat/linux/dev      devfs     rw
tmpfs     /compat/linux/dev/shm  tmpfs     rw,size=1g,mode=1777
fdescfs   /compat/linux/dev/fd   fdescfs   rw,linrdlnk
linprocfs /compat/linux/proc     linprocfs rw
linsysfs  /compat/linux/sys      linsysfs  rw
/tmp      /compat/linux/tmp      nullfs    rw
/home     /compat/linux/home     nullfs    rw

The installed wrapper checks all seven of these at launch and warns on stderr if anything is missing or misconfigured — including the linrdlnk check via /etc/fstab, since mount won't tell you.

On the Scope

The core install logic is genuinely simple: resolve the latest version, download the binary, verify a checksum, install it, write a wrapper. That's maybe forty lines.

The script is about 550 lines because I've spent enough time being challenged by QA teams and real-world users to have a healthy respect for the scenarios you don't anticipate at the whiteboard. I tend toward defensive coding — not just for malicious inputs, but for users: the person with an existing npm install, the system that loses its mounts on reboot, the person who runs the script twice. Every failure mode I can see in advance is one fewer support question later. I'd rather the script be bulletproof and self-explaining than minimal and occasionally baffling.

A few things I picked up during the project that were worth knowing in their own right:

GitHub releases. I'd never built a tool that checks its own version against a GitHub release before. Fetching the releases API, pulling the tag_name from the JSON with a one-line sed, and comparing to the current version is a straightforward pattern — but now I've done it properly rather than just knowing it's possible. The script uses this both for its own --self-update and for the once-per-day nudge for new Claude Code versions.

Remote version channels. downloads.claude.ai exposes plain-text endpoints for latest and stable — just fetch one and you get a version string. Persisting the user's channel preference to a file and reading it back on subsequent runs is obvious in retrospect, but easy to skip and then regret when users expect their choice to stick.

FreeBSD conventions. Binaries managed by the system but not by pkg belong in /usr/local/libexec/. Per-application state goes in /usr/local/share/. The thing on PATH is a wrapper in /usr/local/bin/. I'd used these directories before without having thought explicitly about the reasoning. This project made me look them up properly, which was overdue.

Clean uninstall matters. When Anthropic eventually ships a native FreeBSD binary, I want sudo claude-freebsd --uninstall to remove everything this script placed and nothing else, leaving the system in a clean state for whatever replaces it. The script identifies its own wrapper by a marker comment, and only removes the binary directory if it finds the version sentinel file it writes. ~/.claude/ is never touched.

Using an LLM to Build It

The whole thing was written collaboratively with Claude Code — mildly ironic, since Claude Code's startup hang was what prompted the project. I used it throughout: for the initial script structure, working through the truss output (where, to be fair, we both went down the wrong path together), generating the fstab documentation, and thinking through edge cases in the wrapper logic.

Having something to reason out loud with made the debugging rabbit hole more interesting than tedious, even when the reasoning was wrong. The actual breakthrough came from me accidentally doing the right thing while trying something else entirely — which is, in my experience, roughly how most stubborn bugs get fixed regardless of what tools you're using.

Installation

If you're on FreeBSD amd64 and want to give this a try:

fetch https://raw.githubusercontent.com/insanityinside/claude-freebsd/main/claude-freebsd.sh
chmod +x claude-freebsd.sh
sudo ./claude-freebsd.sh --install

The script will check your Linuxulator setup, warn about any missing or misconfigured mounts, and tell you exactly what to add to /etc/fstab if anything is wrong. The README has the full Linuxulator one-time setup instructions if you're starting from scratch.

Tested on FreeBSD 15.0 amd64. Testing on 14.3 and 14.4 was performed inside a Bastille jail - which also revealed some limitations of the sanity checks when running inside jails, which promptly got fixed and a new version uploaded. Not tested on native FreeBSD 14.x yet, but I'll fire up a VM at some point to test properly.

Feedback from anyone running other FreeBSD versions would be very welcome — please open an issue with your FreeBSD version and the relevant output.