One Binary to Ship Them All: What Claude Code's Move to Bun-in-Rust Means for Your CLI

Claude Code now ships as a single compiled binary via Bun written in Rust. A .NET freelancer's take on why single-file distribution beats npm install -g, with Native AOT examples.

Jean-Pierre Broeders

Freelance .NET Developer

July 19, 20268 min. read
One Binary to Ship Them All: What Claude Code's Move to Bun-in-Rust Means for Your CLI

Today Simon Willison poked around inside the Claude Code binary and found something that made the Hacker News front page: Claude Code is now running Bun compiled in Rust (discussion, 257 points, 342 comments). He counted 563 Rust source filenames baked into the shipped executable, things like src/bundler/bundle_v2.rs. The tool bundles a preview build of Bun (v1.4.0) that hasn't even been tagged publicly yet.

And the punchline? Anthropic's own note said "Startup got 10% faster on Linux but otherwise, barely anyone noticed. Boring is good."

Boring is good. That single line is the most interesting thing in the whole story, and it's why I want to write about it. Because the actual news here isn't Rust, and it isn't Bun. It's that one of the most-used developer tools of the year quietly stopped being "a Node script you install with npm" and became a single binary you download and run. That shift has been coming for years across every ecosystem, .NET included, and most teams still ship their internal CLIs the slow, fragile way.

Let me make the case for the boring path.

The problem with npm install -g

If you've ever shipped a command-line tool as an npm package, you know the failure modes by heart. The user has the wrong Node version. Their global node_modules is corrupted from a half-finished install. A transitive dependency pulled a native module that won't compile on their machine. Corporate proxy blocks the registry. The postinstall script silently fails. You get a bug report that says "it doesn't work" and forty minutes later you discover they were on Node 16.

None of that is your tool's fault. It's the delivery mechanism. A globally installed npm package is not one thing, it's a graph of hundreds of things resolved at install time on a machine you don't control. Every node in that graph is a chance for the install to break.

The same rot exists in Python (pip install, virtualenvs, system Python vs pyenv) and, historically, in .NET too. Remember shipping a tool that needed the exact right runtime installed, and the support ticket that started with "it says the framework is missing"?

A single self-contained binary deletes that entire category of problem. There's one file. It either runs or it doesn't, and if it doesn't, the failure is deterministic and reproducible. That's what Claude Code bought with the Bun compile step: bun build --compile takes your entry point and its whole dependency tree and welds them into one executable with the runtime embedded. No Node on the target machine. No node_modules. No install graph.

Why this is a distribution decision, not a language decision

The Hacker News comments spent a lot of energy on "Rust good, JavaScript bad", and I think that misses it. Anthropic didn't rewrite Claude Code in Rust. The application logic is still TypeScript. What changed is that the runtime executing that TypeScript is now a Rust program (Bun), and the whole thing gets compiled ahead of time into a portable artifact.

That's the distinction I want senior engineers to internalise: your language choice and your distribution choice are separate axes. You can write in a high-level language and still ship a single native-ish binary. The 10% faster startup is nice, but the real win is that the artifact is now atomic. You can sign it, hash it, cache it, and drop it on a machine with zero prerequisites.

For a .NET freelancer this is a familiar story, because .NET has had exactly these tools for a while and a surprising number of teams don't use them.

The .NET version of the same trick

Say you have an internal CLI, a small tool your team runs in CI and on their laptops. The naive way to ship it is "install the .NET SDK, then dotnet tool install". That has the same class of problems as npm: right SDK version, right feed access, right global tool cache.

Here's the boring, robust alternative. A self-contained single-file publish:

dotnet publish -c Release \
  -r linux-x64 \
  --self-contained true \
  -p:PublishSingleFile=true

That produces one executable with the runtime bundled in. The target machine needs nothing installed. Cross-compile for the platforms you care about by swapping the runtime identifier: win-x64, osx-arm64, linux-arm64.

You can go further and strip the parts of the framework you don't use:

<PropertyGroup>
  <PublishSingleFile>true</PublishSingleFile>
  <SelfContained>true</SelfContained>
  <PublishTrimmed>true</PublishTrimmed>
  <RuntimeIdentifier>linux-x64</RuntimeIdentifier>
</PropertyGroup>

Trimming drops your binary from ~70MB down to something in the low tens of megabytes, depending on what you reference. But trimming is where you need to pay attention, because anything reached through reflection can get trimmed away and you won't find out until runtime.

Native AOT: the closest thing to what Claude Code did

If you want the .NET equivalent of "compiled binary, fast cold start, no runtime to JIT", that's Native AOT. It compiles your app straight to native machine code ahead of time. No JIT warmup, no IL, and the startup difference is the part that actually matters for a CLI you invoke a hundred times a day.

<PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>net10.0</TargetFramework>
  <PublishAot>true</PublishAot>
  <InvariantGlobalization>true</InvariantGlobalization>
  <StripSymbols>true</StripSymbols>
</PropertyGroup>
dotnet publish -c Release -r linux-x64

The result is a small native executable that starts in single-digit milliseconds. For a tool a developer runs constantly, that cold-start difference is felt, exactly like Anthropic's "10% faster on Linux". Nobody writes a blog post celebrating it, but everyone stops noticing the tool exists, which is the goal.

Native AOT has real constraints. No runtime code generation, so System.Reflection.Emit is out. System.Text.Json needs its source generator instead of reflection-based serialization. Here's the pattern I use so JSON keeps working under AOT:

using System.Text.Json;
using System.Text.Json.Serialization;

[JsonSerializable(typeof(BuildReport))]
[JsonSerializable(typeof(BuildStep))]
internal partial class CliJsonContext : JsonSerializerContext { }

public record BuildReport(string Commit, IReadOnlyList<BuildStep> Steps);
public record BuildStep(string Name, bool Ok, double Seconds);

// usage
var report = new BuildReport(
    Commit: "a1b2c3d",
    Steps: [ new("restore", true, 4.2), new("build", true, 11.8) ]);

string json = JsonSerializer.Serialize(report, CliJsonContext.Default.BuildReport);
Console.WriteLine(json);

The source generator emits the serialization code at build time, so there's no reflection at runtime and the trimmer keeps its hands off your model. It's a little more ceremony than throwing an object at JsonSerializer.Serialize. In return you get a binary that starts instantly and can't lose your DTOs to the trimmer.

Testing that the artifact actually runs

The failure mode with AOT and trimming is that the thing builds fine and then blows up at runtime because something got trimmed. So the moment you go down this road, your CI needs a smoke test that runs the published binary, not dotnet run. Those are different code paths.

# .github/workflows/cli.yml
name: cli
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-dotnet@v4
        with:
          dotnet-version: '10.0.x'
      - name: Publish AOT
        run: dotnet publish src/MyCli -c Release -r linux-x64
      - name: Smoke test the real binary
        run: |
          BIN=src/MyCli/bin/Release/net10.0/linux-x64/publish/MyCli
          "$BIN" --version
          "$BIN" build --dry-run

Run the actual artifact against a couple of real commands. If a reflection path got trimmed, this catches it before your users do. I've been burned by exactly this: a config loader that used reflection over an enum, worked in every test, died only in the published AOT binary. A ten-second smoke test would have caught it.

When the boring path is the wrong path

I'm not going to pretend single-binary distribution is free. It isn't always the right call.

If your tool is itself a Node or Python library that other people import as a dependency, a compiled binary makes no sense. Publish it to the registry the normal way. The binary approach is for end-user executables, the things people run, not the things people import.

Self-contained binaries are also big and per-platform. You now build and store an artifact for every OS and architecture you support. For a tool distributed to millions of machines, like Claude Code, that trade is obviously worth it. For a five-person internal team on identical Linux runners, shipping a self-contained binary might be overkill when a plain framework-dependent publish plus a documented runtime version does the job. Match the effort to the blast radius.

And trimming plus AOT lengthens your build and constrains your code. If your CLI leans heavily on reflection, dynamic plugins, or Reflection.Emit, forcing it into AOT can cost you more engineering time than the fast startup is worth. Measure before you commit.

What I'm taking from this

The reason this story hit the front page isn't that Rust is fashionable. It's that a hugely popular tool made its distribution invisible, and the team was proud that nobody noticed. That's the bar. A CLI should be one file you download, that runs the same on every machine, that starts fast enough you forget it has a startup cost at all.

.NET has had the pieces for this for years: single-file publish, trimming, and Native AOT. Most of us just keep shipping dotnet tool install and eating the support tickets. Pick your next internal CLI, publish it self-contained, put a smoke test on the real binary in CI, and hand your team one file instead of an install guide. Boring is good.

Want to stay updated?

Subscribe to my newsletter or get in touch for freelance projects.

Get in Touch