mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-10 11:21:06 +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.
89 lines
3.6 KiB
TypeScript
89 lines
3.6 KiB
TypeScript
import { existsSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
const CLI_FILE_NAME = process.platform === "win32" ? "claude.exe" : "claude";
|
|
|
|
let bundledCliDirectory: string | undefined;
|
|
|
|
/**
|
|
* Called by the executable's entry (`src/binary-entry.ts`) before the agent
|
|
* starts. A `bun build --compile` executable has no `node_modules` to resolve
|
|
* the SDK's platform package from, so the release archive ships the Claude CLI
|
|
* next to it. The CLI isn't embedded: a child process can't run a file inside
|
|
* Bun's `$bunfs`, so an embedded copy would have to be extracted to disk first.
|
|
*/
|
|
export function useBundledClaudeCli(directory: string): void {
|
|
bundledCliDirectory = directory;
|
|
}
|
|
|
|
/**
|
|
* The Claude CLI binary to spawn: `CLAUDE_CODE_EXECUTABLE` if set, then the CLI
|
|
* shipped next to the executable, then the SDK's platform-specific package.
|
|
*/
|
|
export async function claudeCliPath(): Promise<string> {
|
|
if (process.env.CLAUDE_CODE_EXECUTABLE) {
|
|
return process.env.CLAUDE_CODE_EXECUTABLE;
|
|
}
|
|
if (bundledCliDirectory !== undefined) {
|
|
return bundledClaudeCliPath(bundledCliDirectory);
|
|
}
|
|
return installedClaudeCliPath();
|
|
}
|
|
|
|
function bundledClaudeCliPath(directory: string): string {
|
|
const path = join(directory, CLI_FILE_NAME);
|
|
// Without this check, spawning a missing CLI fails with a bare ENOENT.
|
|
if (!existsSync(path)) {
|
|
throw new Error(
|
|
`The Claude CLI is missing: expected ${path}. Keep ${CLI_FILE_NAME} from the release archive ` +
|
|
"in the same directory as claude-agent-acp, or set CLAUDE_CODE_EXECUTABLE to a Claude CLI binary.",
|
|
);
|
|
}
|
|
return path;
|
|
}
|
|
|
|
async function installedClaudeCliPath(): Promise<string> {
|
|
// The SDK's CLI is a native binary shipped as a platform-specific optional
|
|
// dependency of @anthropic-ai/claude-agent-sdk. Resolve via a require bound
|
|
// to the SDK so nested installs are found even when npm doesn't hoist.
|
|
const { createRequire } = await import("node:module");
|
|
const req = createRequire(import.meta.resolve("@anthropic-ai/claude-agent-sdk"));
|
|
const ext = process.platform === "win32" ? ".exe" : "";
|
|
// On linux, both glibc and musl variants may be installed side-by-side
|
|
// (e.g. bunx hydrates every optional dep), so picking one by trial is
|
|
// unreliable: the wrong binary segfaults at runtime instead of failing to
|
|
// spawn. Detect the runtime libc and prefer the matching variant, falling
|
|
// back to the other only if the preferred one isn't installed.
|
|
const candidates =
|
|
process.platform === "linux"
|
|
? isMuslLibc()
|
|
? [
|
|
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}-musl/claude${ext}`,
|
|
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}/claude${ext}`,
|
|
]
|
|
: [
|
|
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}/claude${ext}`,
|
|
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}-musl/claude${ext}`,
|
|
]
|
|
: [`@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}/claude${ext}`];
|
|
for (const candidate of candidates) {
|
|
try {
|
|
return req.resolve(candidate);
|
|
} catch {
|
|
// try next candidate
|
|
}
|
|
}
|
|
throw new Error(
|
|
`Claude native binary not found for ${process.platform}-${process.arch}. ` +
|
|
`Reinstall @anthropic-ai/claude-agent-sdk without --omit=optional, or set CLAUDE_CODE_EXECUTABLE.`,
|
|
);
|
|
}
|
|
|
|
function isMuslLibc(): boolean {
|
|
// process.report.getReport().header.glibcVersionRuntime is populated when
|
|
// Node is dynamically linked against glibc, and absent on musl.
|
|
const report = process.report?.getReport() as
|
|
{ header?: { glibcVersionRuntime?: string } } | undefined;
|
|
return !report?.header?.glibcVersionRuntime;
|
|
}
|