Skip to content

Instantly share code, notes, and snippets.

@vgrichina
Created May 15, 2026 00:03
Show Gist options
  • Select an option

  • Save vgrichina/2f69d738b4227ea9c1c91efe30a3c24f to your computer and use it in GitHub Desktop.

Select an option

Save vgrichina/2f69d738b4227ea9c1c91efe30a3c24f to your computer and use it in GitHub Desktop.
Run Claude Code (Bun ELF) under Node.js — Bun.* polyfill + cli.js launcher

Run Claude Code under Node.js (no Bun)

The official claude CLI ships as a Bun-compiled standalone ELF (Linux/amd64 or Linux/arm64). On platforms where Bun won't run — Android (bionic libc, no glibc), restricted environments, or anywhere you only have Node available — you can still run claude by:

  1. Unpacking cli.js (the embedded JS bundle) out of the ELF.
  2. Running it under Node.js with a small Bun.* polyfill (bun-shim.js) and a launcher (launch.cjs) that handles bun's CJS wrapper + stubs the missing native .node addons.

This was developed for the Minimal Phone (Android, 600×800 e-ink, ARM64), where Bun's raw svc #0 syscalls bypass LD_PRELOAD so the usual glibc-shim trick can't fix networking. Running the JS under bionic Node sidesteps that — bionic auto-tags sockets via fwmarkd for per-UID routing.

Files in this gist

File Role
bun-shim.js Polyfills Bun.* (loaded via node -r).
launch.cjs Strips bun's CJS wrapper, stubs .node addons, runs cli.js.
README.md This file.

You provide:

  • cli.js — extracted from the official claude ELF (see below).
  • wsnpm install ws@8.18.2 (or vendor it).
  • Node.js — tested with v20+.

Extracting cli.js from the ELF

Download the matching Linux build from https://downloads.claude.ai/claude-code-releases/:

VERSION=$(curl -fsSL https://downloads.claude.ai/claude-code-releases/latest)
curl -fsSL "https://downloads.claude.ai/claude-code-releases/$VERSION/linux-arm64/claude" -o claude-elf
chmod +x claude-elf

cli.js is sandwiched between bun's marker // @bun @bytecode @bun-cjs and the trailing }) of its CJS wrapper. Extract:

node -e '
  const fs = require("fs");
  const buf = fs.readFileSync("./claude-elf");
  const marker = Buffer.from("// @bun @bytecode @bun-cjs");
  const start = buf.indexOf(marker);
  if (start < 0) throw new Error("marker not found");
  // Scan forward for the matching "})\n<trailer>" — claude is the only big JS
  // blob in the ELF, so the next NUL or non-ASCII byte after a "})" is reliable.
  let end = start;
  while (end < buf.length) {
    if (buf[end] === 0x29 /* ) */ && buf[end-1] === 0x7d /* } */) {
      // Peek a bit past — if we hit binary, this is the end.
      const ahead = buf.slice(end+1, end+64);
      if (ahead.some(b => b === 0 || (b > 0 && b < 9) || (b > 13 && b < 32 && b !== 27))) {
        end++; break;
      }
    }
    end++;
  }
  fs.writeFileSync("cli.js", buf.slice(start, end));
  console.log("wrote cli.js,", (end-start), "bytes");
'

(For version 2.1.141 this produces a ~14MB file. If your extraction looks truncated, widen the scan or just slice from start to the end of the file and trim the binary tail manually — there's only one trailing }) that matters.)

Run

# layout:
#   ./bun-shim.js
#   ./launch.cjs
#   ./cli.js          (extracted)
#   ./node_modules/ws (npm install ws@8.18.2)

node -r ./bun-shim.js ./launch.cjs --version
node -r ./bun-shim.js ./launch.cjs        # full TUI

The shim's virtual-module registry intercepts require('ws') and require('undici')ws resolves to the real package (vendored or installed), undici is stubbed to globalThis.fetch.

What the shim covers

bun-shim.js polyfills the subset of Bun.* that cli.js actually invokes:

  • Bun.version, Bun.stringWidth, Bun.stripANSI, Bun.wrapAnsi
  • Bun.spawn, Bun.spawnSync, Bun.which, Bun.listen
  • Bun.hash, Bun.Transpiler, Bun.YAML (stub), Bun.JSONL, Bun.semver, Bun.Terminal, Bun.stdin, Bun.gc
  • Bun.embeddedFiles = [{name:'cli.js', size:0}] — claude checks this to decide it's a "native install" and skip the "switched to native" banner.

Plus the virtual-module registry hooked into Module._resolveFilename / _load so require('ws') and require('undici') resolve without depending on the bun runtime resolver.

Verified

  • Fresh OAuth login (Claude Max): theme picker → login-method picker → manual-redirect URL display → paste callback code → credentials written to ~/.claude/.credentials.json → live Opus session with tool use.
  • Default Max login uses MANUAL_REDIRECT_URL=https://platform.claude.com/oauth/code/callback, so no local-loopback HTTP server is needed.

Known gaps

  • Bun.YAML throws if claude ever reaches it (not exercised in practice).
  • Bun.listen(0) returns the requested port verbatim. Node's server.listen(0) only exposes the OS-assigned port async (via listening event), with no clean way to surface it synchronously without worker_threads + Atomics.wait. Affects custom-OAuth / egress-gateway paths; default Max login doesn't hit it.
  • MCP over websockets should work (real ws is vendored), not end-to-end exercised here.

License

The polyfill and launcher in this gist (bun-shim.js, launch.cjs) are MIT. cli.js (and the claude ELF it comes from) is Anthropic's — see their terms of service. Don't redistribute the binary or the unpacked cli.js; extract it yourself from the official release.

// Minimal Bun-API shim for running Bun-compiled claude under Node.js.
// Loaded with `node -r ./bun-shim.js launch.cjs ...` (or required from a wrapper).
// Only fills the calls cli.js actually makes. Add more as we hit ReferenceErrors.
'use strict';
const child_process = require('child_process');
const fs_module = require('fs');
const fsp = require('fs/promises');
const path_module = require('path');
const os = require('os');
const net = require('net');
const { Readable } = require('stream');
if (typeof globalThis.Bun !== 'undefined') {
module.exports = globalThis.Bun;
} else (function installBunShim() {
// --- Virtual module registry: claude's cli.js was bundled by bun and
// require()s a few npm packages that don't have node-builtin equivalents.
// We provide tiny stubs so the require() succeeds; full functionality only
// matters for the specific features (MCP websocket, undici HTTP) that the
// stubs cover. ---
const Module = require('module');
const VIRTUAL = new Map();
function virtualModule(name, factory) {
VIRTUAL.set(name, factory);
}
const origResolve = Module._resolveFilename;
Module._resolveFilename = function (req, parent, ...rest) {
if (VIRTUAL.has(req)) return req;
return origResolve.call(this, req, parent, ...rest);
};
const origLoad = Module._load;
Module._load = function (req, parent, ...rest) {
if (VIRTUAL.has(req)) {
const cached = Module._cache[req];
if (cached) return cached.exports;
const m = new Module(req);
m.filename = req;
m.loaded = true;
Module._cache[req] = m;
try { m.exports = VIRTUAL.get(req)(); } catch (e) { delete Module._cache[req]; throw e; }
return m.exports;
}
return origLoad.call(this, req, parent, ...rest);
};
// 'ws' — used for MCP websocket connections. Load the real package from a
// vendored copy sitting next to this shim. Pure JS, ~130KB, no native deps.
virtualModule('ws', () => {
const path = require('path');
return require(path.join(__dirname, 'vendor', 'ws'));
});
// 'undici' — claude uses for some HTTP calls; node has globalThis.fetch
// natively. Map undici's most-used exports to node primitives.
virtualModule('undici', () => {
const u = {};
u.fetch = globalThis.fetch;
u.Headers = globalThis.Headers;
u.Request = globalThis.Request;
u.Response = globalThis.Response;
u.FormData = globalThis.FormData;
u.Agent = class Agent { constructor(opts) { this.opts = opts; } };
u.ProxyAgent = class ProxyAgent { constructor(opts) { this.opts = opts; } };
u.setGlobalDispatcher = () => {};
u.getGlobalDispatcher = () => null;
u.errors = { UndiciError: class UndiciError extends Error {} };
return u;
});
// --- ANSI strip first (string width depends on it) ---
const ANSI_RE = /[›][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d/#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d/#&.:=?%@~_]*)*)?)|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PRZcf-nq-uy=><~]))/g;
function stripAnsi(s) { return String(s ?? '').replace(ANSI_RE, ''); }
// --- string width (East Asian Width — coarse, no deps) ---
function stringWidthImpl(s) {
s = stripAnsi(String(s ?? ''));
let w = 0;
for (const ch of s) {
const c = ch.codePointAt(0);
if (c == null) continue;
if (c < 32 || c === 127) continue;
if ((c >= 0x1100 && c <= 0x115F) ||
(c >= 0x2E80 && c <= 0x303E) ||
(c >= 0x3041 && c <= 0x33FF) ||
(c >= 0x3400 && c <= 0x4DBF) ||
(c >= 0x4E00 && c <= 0x9FFF) ||
(c >= 0xA000 && c <= 0xA4CF) ||
(c >= 0xAC00 && c <= 0xD7A3) ||
(c >= 0xF900 && c <= 0xFAFF) ||
(c >= 0xFE30 && c <= 0xFE4F) ||
(c >= 0xFF00 && c <= 0xFF60) ||
(c >= 0xFFE0 && c <= 0xFFE6) ||
(c >= 0x20000 && c <= 0x2FFFD) ||
(c >= 0x30000 && c <= 0x3FFFD)) w += 2;
else w += 1;
}
return w;
}
function wrapAnsi(s, columns = 80, opts = {}) {
const text = String(s ?? '');
if (columns <= 0) return text;
const out = [];
for (const line of text.split('\n')) {
if (stringWidthImpl(line) <= columns) { out.push(line); continue; }
let buf = '', w = 0;
for (const ch of line) {
const cw = stringWidthImpl(ch);
if (w + cw > columns) { out.push(buf); buf = ''; w = 0; }
buf += ch; w += cw;
}
if (buf) out.push(buf);
}
return out.join('\n');
}
// --- spawn (sync + async) ---
function bunSpawn(arg1, arg2) {
// bun.spawn supports two signatures: spawn({cmd, ...}) and spawn(cmdArr, opts)
let cmd, opts;
if (Array.isArray(arg1)) { cmd = arg1; opts = arg2 || {}; }
else { cmd = arg1.cmd; opts = arg1; }
const file = cmd[0];
const args = cmd.slice(1);
const stdio = [
opts.stdin === 'inherit' ? 'inherit' : (opts.stdin === 'ignore' ? 'ignore' : 'pipe'),
opts.stdout === 'inherit' ? 'inherit' : (opts.stdout === 'ignore' ? 'ignore' : 'pipe'),
opts.stderr === 'inherit' ? 'inherit' : (opts.stderr === 'ignore' ? 'ignore' : 'pipe'),
];
const child = child_process.spawn(file, args, {
cwd: opts.cwd,
env: opts.env || process.env,
stdio,
});
// Bun returns a Subprocess object with .exited (Promise), .pid, .stdin, .stdout, .stderr, .kill()
const result = {
pid: child.pid,
stdin: child.stdin,
stdout: child.stdout,
stderr: child.stderr,
kill: (sig) => child.kill(sig),
exited: new Promise((res) => child.on('exit', (code) => res(code ?? 0))),
exitCode: null,
killed: false,
};
child.on('exit', (code) => { result.exitCode = code ?? 0; });
return result;
}
function bunSpawnSync(arg1, arg2) {
let cmd, opts;
if (Array.isArray(arg1)) { cmd = arg1; opts = arg2 || {}; }
else { cmd = arg1.cmd; opts = arg1; }
const r = child_process.spawnSync(cmd[0], cmd.slice(1), {
cwd: opts.cwd,
env: opts.env || process.env,
input: opts.stdin && opts.stdin !== 'inherit' && opts.stdin !== 'pipe' && opts.stdin !== 'ignore' ? opts.stdin : undefined,
});
return {
pid: r.pid,
stdout: r.stdout ?? Buffer.alloc(0),
stderr: r.stderr ?? Buffer.alloc(0),
exitCode: r.status ?? 0,
success: r.status === 0,
signal: r.signal,
};
}
// --- which ---
function which(cmd, opts = {}) {
const PATH = (opts.PATH ?? process.env.PATH ?? '').split(path_module.delimiter);
const exts = process.platform === 'win32' ? (process.env.PATHEXT || '').split(';') : [''];
for (const dir of PATH) {
if (!dir) continue;
for (const ext of exts) {
const p = path_module.join(dir, cmd + ext);
try {
const st = fs_module.statSync(p);
if (st.isFile()) return p;
} catch {}
}
}
return null;
}
// --- listen (TCP server, Bun.listen / Bun.connect style) ---
function bunListen(opts) {
const { hostname = '0.0.0.0', port, socket: handlers = {} } = opts;
const server = net.createServer((sock) => {
const ctx = sock; // pass the raw net.Socket as "socket" arg
try { handlers.open && handlers.open(ctx); } catch (e) { handlers.error && handlers.error(ctx, e); }
sock.on('data', (chunk) => { try { handlers.data && handlers.data(ctx, chunk); } catch (e) { handlers.error && handlers.error(ctx, e); } });
sock.on('close', () => { try { handlers.close && handlers.close(ctx); } catch {} });
sock.on('error', (e) => { try { handlers.error && handlers.error(ctx, e); } catch {} });
});
server.listen(port, hostname);
return {
hostname, port,
stop: () => server.close(),
[Symbol.dispose]: () => server.close(),
};
}
// --- hash (Bun.hash(input) -> bigint; algorithm-specific variants) ---
const crypto = require('crypto');
function bunHash(input, seed) {
// wyhash equivalent — we'll just use sha256 trimmed to u64 (claude only uses for caching keys)
const h = crypto.createHash('sha256');
if (seed !== undefined) h.update(String(seed));
h.update(typeof input === 'string' ? input : Buffer.from(input));
const buf = h.digest();
return buf.readBigUInt64LE(0);
}
bunHash.wyhash = bunHash;
// --- Transpiler (TS/JSX) — claude code internal eval of user .ts? Stub to identity for .js, error for .ts ---
class BunTranspiler {
constructor(opts = {}) { this.opts = opts; }
transformSync(code) { return String(code); }
transform(code) { return Promise.resolve(String(code)); }
scan() { return { imports: [], exports: [] }; }
scanImports() { return []; }
}
// --- YAML / JSONL — claude uses for streamed responses and config? minimal stubs ---
// YAML stub: claude likely uses for tool descriptions / config; JSON fallback works for JSON-shaped input.
const YAML = {
parse: (s) => { throw new Error('Bun.YAML.parse: no yaml impl in shim (call site=' + (new Error().stack.split('\n')[2] || '?') + ')'); },
stringify: (o) => JSON.stringify(o, null, 2),
};
const JSONL = {
parse: (s) => String(s ?? '').split('\n').filter(Boolean).map((line) => JSON.parse(line)),
stringify: (arr) => (Array.isArray(arr) ? arr : [arr]).map((o) => JSON.stringify(o)).join('\n') + '\n',
};
// --- semver (minimal, no deps; sufficient for x.y.z >= a.b.c comparisons) ---
function parseSemver(v) {
const m = String(v).match(/^v?(\d+)\.(\d+)\.(\d+)/);
return m ? [+m[1], +m[2], +m[3]] : [0, 0, 0];
}
function cmpSemver(a, b) {
const [a1,a2,a3] = parseSemver(a), [b1,b2,b3] = parseSemver(b);
return a1!==b1 ? a1-b1 : a2!==b2 ? a2-b2 : a3-b3;
}
const semver = {
satisfies: (v, r) => {
// Very loose: support ">=x.y.z", "x.y.z", "*"
if (r === '*' || !r) return true;
const m = String(r).match(/^([<>=!~^]*)\s*(.+)$/);
if (!m) return true;
const c = cmpSemver(v, m[2]);
const op = m[1] || '=';
if (op.includes('=') && c === 0) return true;
if (op.includes('>') && c > 0) return true;
if (op.includes('<') && c < 0) return true;
return false;
},
order: cmpSemver,
};
// --- Terminal — stdin/stdout helpers (used for raw mode etc) ---
class BunTerminal {
constructor() {}
// Minimal: just expose process.stdin/stdout
get input() { return process.stdin; }
get output() { return process.stdout; }
}
// --- stdin (Bun.stdin is a Blob-like for the program's stdin) ---
const bunStdin = {
stream: () => Readable.toWeb ? Readable.toWeb(process.stdin) : process.stdin,
text: async () => {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
return Buffer.concat(chunks).toString('utf8');
},
arrayBuffer: async () => {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
const buf = Buffer.concat(chunks);
return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
},
json: async () => JSON.parse(await bunStdin.text()),
};
// --- generateHeapSnapshot / gc ---
function generateHeapSnapshot() { return { type: 'heap-snapshot', nodes: [], edges: [] }; }
function bunGc(sync) {
if (global.gc) global.gc();
return 0;
}
// --- embeddedFiles: claude uses this to detect "native bun standalone" vs
// "npm install". Empty = looks like npm and prints a deprecation warning.
// Provide a placeholder entry so claude treats us as the native build. ---
const embeddedFiles = [{ name: 'cli.js', size: 0 }];
// --- Assemble Bun global ---
const Bun = {
version: '1.3.10', // pretend
revision: 'bun-shim',
argv: process.argv,
env: process.env,
main: require.main && require.main.filename,
// strings
stringWidth: stringWidthImpl,
stripANSI: stripAnsi,
wrapAnsi: wrapAnsi,
// process
spawn: bunSpawn,
spawnSync: bunSpawnSync,
which,
// network
listen: bunListen,
// hashing
hash: bunHash,
// parsers
Transpiler: BunTranspiler,
YAML,
JSONL,
semver,
// I/O
Terminal: BunTerminal,
stdin: bunStdin,
embeddedFiles,
// debug
gc: bunGc,
generateHeapSnapshot,
// misc
nanoseconds: () => Number(process.hrtime.bigint()),
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
sleepSync: (ms) => { const end = Date.now() + ms; while (Date.now() < end) {} },
inspect: (v) => require('util').inspect(v),
fileURLToPath: (u) => new URL(u).pathname,
pathToFileURL: (p) => 'file://' + p,
};
globalThis.Bun = Bun;
module.exports = Bun;
})();
#!/usr/bin/env bun
// Launcher for an unpacked Bun-compiled claude bundle.
// - stubs the bunfs-resident native .node addons that won't load outside the real binary
// - strips the bun wrapper from cli.js and writes the body as cli-body.cjs
// - require()s it (bun's CJS loader handles 14MB files fine; eval() chokes on Android arm64)
const path = require('path');
const fs = require('fs');
const Module = require('module');
const STUB_PATHS = new Set([
'/$bunfs/root/audio-capture.node',
'/$bunfs/root/computer-use-input.node',
'/$bunfs/root/computer-use-swift.node',
'/$bunfs/root/image-processor.node',
'/$bunfs/root/url-handler.node',
]);
const origResolve = Module._resolveFilename;
Module._resolveFilename = function (req, parent, ...rest) {
if (STUB_PATHS.has(req)) return req;
return origResolve.call(this, req, parent, ...rest);
};
const origLoad = Module._load;
Module._load = function (req, parent, ...rest) {
if (STUB_PATHS.has(req)) {
return new Proxy({}, {
get: (_, k) => {
if (k === Symbol.toPrimitive || k === 'then') return undefined;
return () => null;
},
});
}
return origLoad.call(this, req, parent, ...rest);
};
// cli.js is "// @bun @bytecode @bun-cjs\n(function(exports, require, module, __filename, __dirname) {BODY})".
// Strip the marker and the wrapper, then build a Function from BODY directly —
// avoids eval()'s parser quirks on Android arm64 and bun's CJS-loader insistence
// on a particular wrapper shape.
const src = fs.readFileSync(path.join(__dirname, 'cli.js'), 'utf8');
const prefix = '// @bun @bytecode @bun-cjs\n(function(exports, require, module, __filename, __dirname) {';
if (!src.startsWith(prefix)) throw new Error('cli.js does not start with expected bun wrapper');
const trimmed = src.replace(/\s+$/, '');
if (!trimmed.endsWith('})')) throw new Error('cli.js does not end with expected closing })');
const body = trimmed.slice(prefix.length, trimmed.length - 2);
const fn = new Function('exports', 'require', 'module', '__filename', '__dirname', body);
const m = new Module(__filename, module.parent);
m.filename = path.join(__dirname, 'cli.js');
m.paths = Module._nodeModulePaths(__dirname);
fn(m.exports, require, m, m.filename, __dirname);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment