mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-05 17:01:44 +00:00
Brings back the standalone executables that #525 and #572 turned off. npm publishing is unchanged. Each stable release also attaches 8 archives (Linux glibc and musl, macOS, Windows, on x64 and arm64) to its GitHub release. ## Why it works now The old executables ran the SDK's JS CLI from inside Bun's `$bunfs`, and it couldn't reach its `vendor/` files there. The SDK's CLI is now a native binary shipped in a per-platform npm package. So each archive ships that binary as `claude` next to `claude-agent-acp`, and the agent runs it from its own directory. Nothing is embedded or extracted, and the agent has no `node_modules` to resolve. The first commit embedded the CLI and extracted it with the SDK's `@anthropic-ai/claude-agent-sdk/extract` helper. That works, but the OS can only run a real file, and Bun-embedded files only exist inside the agent process. So it wrote a second ~300 MB copy to `/tmp` on first run, once per version. Shipping the CLI next to the executable avoids that, and the download size is the same (~180 MB `.tar.gz`). Claude Code's helper tools work too. Native Claude Code has no Grep or Glob tools: its Bash shell wraps `grep` and `find` in functions that run the ugrep and bfs built into the binary at `$CLAUDE_CODE_EXECPATH`. That variable points at the `claude` shipped next to the agent. ## Changes - `src/claude-cli.ts` now owns `claudeCliPath()`, moved out of `acp-agent.ts`. `useBundledClaudeCli(dir)` makes it return `<dir>/claude[.exe]`. If that file is missing, it throws an error saying to keep `claude` next to `claude-agent-acp` or set `CLAUDE_CODE_EXECUTABLE`; before, the user got a bare ENOENT. `CLAUDE_CODE_EXECUTABLE` still wins. - `src/binary-entry.ts` is the executable's entry. It registers the directory of `realpath(process.execPath)`, so a symlink in `/usr/local/bin` works, then imports the agent. It's left out of the npm tarball. - `scripts/build-binary.mjs` (`npm run build:binary`) runs `bun build --compile` for a target and copies that target's `claude` next to the output. - `scripts/smoke-binary.mjs` (`npm run smoke:binary`) runs: - `--version` and `--cli --version` - a symlinked executable - the missing-`claude` error - `initialize` over v1 and v2 - the `terminal-auth` login command, run with `--version` (checks that no `$bunfs` path leaks into it) With `--full` it also drives a real session: - Bash `grep`/`find`, checking that they ran Claude's embedded ugrep/bfs - Read on text and on a PNG - Edit - a subagent - `session/cancel` during a running command - `session/list` - `session/load` in a new process - `session/fork` and `session/delete` - `src/agent-command.ts` builds the `terminal-auth` login command, which reruns the agent. Under Node that's `node <script> ...flags`. In a compiled executable, `argv[1]` is a path inside the executable (`/$bunfs/root/claude-agent-acp`), so it's dropped. Passing it back, as the old `argv.slice(1)` did, handed it to the Claude CLI as an argument and broke `auth login`. - `.github/workflows/binaries.yml` builds and smoke-tests each target on its own platform's runner; the musl targets are tested in an Alpine container. CI runs it on every PR, using the basic smoke test, since CI has no Claude login. On a stable release, `publish.yml` attaches the archives (same names as before). These jobs don't gate the npm publish, and previews get no executables. On stable releases the registry dispatch waits for `upload-binaries`, so the archives (and the digests the registry reads) exist first. It only waits: a failed upload doesn't stop the dispatch. ## Testing I also ran the whole unit suite on Bun's runtime (`bun --bun vitest run`). 3,573 of 3,575 tests pass. The two failures are test artifacts: Bun's `os.homedir()` ignores a `HOME` changed at runtime, and one steer-and-cancel test asserts before the mock delivers its last idle. An idle-accounting trace is identical on both runtimes. On macOS arm64 with Bun 1.4.4, `smoke:binary --full` passes all checks. `CLAUDE_CODE_EXECPATH` pointed at the shipped `claude`, and nothing was written to the temp directory. The basic smoke test also passes from an unpacked archive outside the repo with `PATH=/usr/bin:/bin`, and `node dist/index.js` passes the full run. On Linux, Windows and Intel macOS, only the basic smoke test runs, in this PR's CI.
93 lines
3.5 KiB
JavaScript
93 lines
3.5 KiB
JavaScript
#!/usr/bin/env node
|
|
// Builds an executable of the agent with `bun build --compile` and copies the
|
|
// target platform's Claude CLI next to it. Both files go in the release
|
|
// archive: the executable runs the CLI from its own directory.
|
|
//
|
|
// node scripts/build-binary.mjs [--target bun-linux-x64-musl] [--outfile path]
|
|
//
|
|
// --target defaults to the host. Cross-compiling needs the target's SDK
|
|
// platform package installed; the script prints the install command when it
|
|
// is missing.
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import { chmodSync, copyFileSync, readFileSync } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import { dirname, join, resolve } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { parseArgs } from "node:util";
|
|
|
|
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
|
|
function hostTarget() {
|
|
const os = process.platform === "win32" ? "windows" : process.platform;
|
|
// Node linked against glibc reports its version; musl builds don't.
|
|
const musl =
|
|
process.platform === "linux" && !process.report.getReport().header.glibcVersionRuntime;
|
|
return `bun-${os}-${process.arch}${musl ? "-musl" : ""}`;
|
|
}
|
|
|
|
const { values } = parseArgs({
|
|
options: {
|
|
target: { type: "string", default: hostTarget() },
|
|
outfile: { type: "string" },
|
|
},
|
|
});
|
|
const target = values.target;
|
|
|
|
// Bun targets look like `bun-<os>-<arch>` with optional `-musl`, `-baseline` or
|
|
// `-modern` suffixes; the SDK names its packages `<platform>-<arch>[-musl]`.
|
|
const match = /^bun-(linux|darwin|windows)-(x64|arm64|aarch64)(?:-.+)?$/.exec(target);
|
|
if (!match) {
|
|
console.error(
|
|
`Unsupported target "${target}". Expected e.g. bun-linux-x64, bun-darwin-arm64, bun-windows-x64.`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const [, os, rawArch] = match;
|
|
const platform = os === "windows" ? "win32" : os;
|
|
const arch = rawArch === "aarch64" ? "arm64" : rawArch;
|
|
const musl = target.split("-").includes("musl");
|
|
const exe = os === "windows" ? ".exe" : "";
|
|
const cliPackage = `@anthropic-ai/claude-agent-sdk-${platform}-${arch}${musl ? "-musl" : ""}`;
|
|
const cliSpecifier = `${cliPackage}/claude${exe}`;
|
|
const outfile = resolve(values.outfile ?? join(repoRoot, "dist-bin", `claude-agent-acp${exe}`));
|
|
|
|
// Resolve from the SDK, the way claudeCliPath() does at runtime, so a nested
|
|
// install is checked rather than only a hoisted one.
|
|
const sdkRequire = createRequire(
|
|
createRequire(join(repoRoot, "package.json")).resolve("@anthropic-ai/claude-agent-sdk"),
|
|
);
|
|
let cliPath;
|
|
try {
|
|
cliPath = sdkRequire.resolve(cliSpecifier);
|
|
} catch {
|
|
const sdkVersion = JSON.parse(
|
|
readFileSync(
|
|
join(dirname(sdkRequire.resolve("@anthropic-ai/claude-agent-sdk")), "package.json"),
|
|
"utf8",
|
|
),
|
|
).version;
|
|
console.error(
|
|
`${cliSpecifier} is not installed. npm only installs the host's platform package; for ${target} run:\n\n` +
|
|
` npm install --no-save --force ${cliPackage}@${sdkVersion}\n`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const bun = spawnSync(
|
|
"bun",
|
|
["build", "src/binary-entry.ts", "--compile", "--target", target, "--outfile", outfile],
|
|
{ cwd: repoRoot, stdio: "inherit", shell: process.platform === "win32" },
|
|
);
|
|
if (bun.error) {
|
|
console.error(`Could not run bun: ${bun.error.message}. Install it from https://bun.sh.`);
|
|
process.exit(1);
|
|
}
|
|
if (bun.status !== 0) process.exit(bun.status ?? 1);
|
|
|
|
// The executable runs the CLI from its own directory (src/binary-entry.ts).
|
|
const cliOutfile = join(dirname(outfile), `claude${exe}`);
|
|
copyFileSync(cliPath, cliOutfile);
|
|
chmodSync(cliOutfile, 0o755);
|
|
console.log(`Copied ${cliSpecifier} to ${cliOutfile}`);
|