experiment(desktop): merge server + cli into one sidecar binary

Replaces the two ~65MB bun-compiled sidecar binaries with a single
~66MB merged binary. The bun runtime + shared dependency code (anthropic
SDK, MCP SDK, ws, undici, etc) was previously duplicated across both —
merging eliminates that duplication entirely.

Combined with the previous P0 commit (static-import inlining + drop
src/ + node_modules/ from Resources), this brings the macOS .app from
the original 435MB baseline down to 87MB (-80%), and the DMG from 113MB
to 37MB (-67%).

Final breakdown of the 87MB .app:
  Contents/MacOS/claude-sidecar         66MB   (was 57+57=114MB)
  Contents/MacOS/claude-code-desktop    18MB   (Tauri Rust main)
  Contents/Resources/icon.icns           2MB
  + plist + frameworks                  ~1MB

This is essentially the floor — bun runtime + Tauri main + minimum
overhead. Going lower would require swapping toolchains.

Implementation
==============

* desktop/sidecars/claude-sidecar.ts (new): single entrypoint that
  takes a positional mode argument ("server" or "cli") then dispatches
  via `await import('../../src/server/index.ts').startServer()` or
  `await import('../../src/entrypoints/cli.tsx')`. Same env / argv setup
  pattern as the old launchers.
* desktop/sidecars/server-launcher.ts + cli-launcher.ts: deleted.
* desktop/scripts/build-sidecars.ts: compiles only claude-sidecar now.
* desktop/src-tauri/tauri.conf.json: externalBin → ["binaries/claude-sidecar"]
* desktop/src-tauri/src/lib.rs: spawns sidecar with leading "server"
  positional arg.
* src/server/services/conversationService.ts resolveBundledCliPath /
  resolveCliArgs: when current process is claude-sidecar, reuses the
  same exe and spawns it with leading "cli" positional arg. Backward
  compat path for old claude-server / claude-cli pair preserved for the
  bin/claude-haha dev mode.

Verification
============

* claude-sidecar cli --version → 999.0.0-local (Claude Code) ✓
* claude-sidecar cli --help → full Commander spec ✓
* claude-sidecar server --port N → HTTP listening, CronScheduler running ✓
* All three above run in /tmp with no src/ or node_modules/ on disk
* bun test on src/ → 358 pass / 45 fail / 1 error, identical to baseline
  (44 fails are pre-existing on main, unrelated to this change)
* Full DMG round-trip via build-macos-arm64.sh succeeds; new .app
  installs cleanly in /Volumes/

Bundle size summary (vs original baseline)
==========================================

  metric              baseline   final      delta
  .app total          435 MB     87 MB      -348 MB (-80%)
  .dmg                113 MB     37 MB      -76 MB  (-67%)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
程序员阿江(Relakkes) 2026-04-12 00:10:47 +08:00
parent 662485c3eb
commit 3c5549e221
7 changed files with 106 additions and 105 deletions

View File

@ -26,21 +26,17 @@ if (scanExit !== 0) {
await mkdir(binariesDir, { recursive: true })
// 单一合并 sidecarserver / cli 共享一份 bun runtime + 共享依赖代码。
// 调用方Tauri lib.rs / conversationService通过第一个 positional 参数
// 选择 'server' 或 'cli' 模式,详见 desktop/sidecars/claude-sidecar.ts。
await compileExecutable({
entrypoint: path.join(desktopRoot, 'sidecars/server-launcher.ts'),
outfileBase: path.join(binariesDir, `claude-server-${targetTriple}`),
productName: 'Claude Code Server',
entrypoint: path.join(desktopRoot, 'sidecars/claude-sidecar.ts'),
outfileBase: path.join(binariesDir, `claude-sidecar-${targetTriple}`),
productName: 'Claude Code Sidecar',
bunTarget,
})
await compileExecutable({
entrypoint: path.join(desktopRoot, 'sidecars/cli-launcher.ts'),
outfileBase: path.join(binariesDir, `claude-cli-${targetTriple}`),
productName: 'Claude Code CLI',
bunTarget,
})
console.log(`[build-sidecars] Built desktop sidecars for ${targetTriple} (${bunTarget})`)
console.log(`[build-sidecars] Built desktop sidecar for ${targetTriple} (${bunTarget})`)
async function detectHostTriple() {
const proc = Bun.spawn(['rustc', '-vV'], {

View File

@ -0,0 +1,64 @@
/**
* Claude Code sidecar
*
* server cli bun-compile
* ~55MB bun runtime runtime
* P0
*
* positional
*
* claude-sidecar server --app-root <path> --host 127.0.0.1 --port 12345
* claude-sidecar cli --app-root <path> [ CLI ...]
*
* process.env / process.argv await
* server-launcher.ts / cli-launcher.ts
* src/server/index.ts process.argv splice
* --app-root mode launcher-only
*/
const rawArgs = process.argv.slice(2)
if (rawArgs.length === 0) {
console.error('claude-sidecar: missing mode argument (expected "server" or "cli")')
process.exit(2)
}
const mode = rawArgs[0]!
const restArgs = rawArgs.slice(1)
const { appRoot, args } = parseLauncherArgs(restArgs)
process.env.CLAUDE_APP_ROOT = appRoot
process.env.CALLER_DIR ||= process.cwd()
process.argv = [process.argv[0]!, process.argv[1]!, ...args]
await import('../../preload.ts')
if (mode === 'server') {
const { startServer } = await import('../../src/server/index.ts')
startServer()
} else if (mode === 'cli') {
await import('../../src/entrypoints/cli.tsx')
} else {
console.error(`claude-sidecar: unknown mode "${mode}" (expected "server" or "cli")`)
process.exit(2)
}
function parseLauncherArgs(rawArgs: string[]): { appRoot: string; args: string[] } {
const nextArgs: string[] = []
let appRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null
for (let index = 0; index < rawArgs.length; index++) {
const arg = rawArgs[index]
if (arg === '--app-root') {
appRoot = rawArgs[index + 1] ?? null
index += 1
continue
}
nextArgs.push(arg!)
}
if (!appRoot) {
throw new Error('Missing --app-root for claude-sidecar')
}
return { appRoot, args: nextArgs }
}

View File

@ -1,38 +0,0 @@
/**
* Claude Code CLI sidecar
*
* server-launcher.ts specifier dynamic import
* `bun build --compile` src/entrypoints/cli.tsx
* src/ node_modules/
*/
const { appRoot, args } = parseLauncherArgs()
process.env.CLAUDE_APP_ROOT = appRoot
process.env.CALLER_DIR ||= process.cwd()
process.argv = [process.argv[0]!, process.argv[1]!, ...args]
await import('../../preload.ts')
await import('../../src/entrypoints/cli.tsx')
function parseLauncherArgs() {
const rawArgs = process.argv.slice(2)
const nextArgs: string[] = []
let appRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null
for (let index = 0; index < rawArgs.length; index++) {
const arg = rawArgs[index]
if (arg === '--app-root') {
appRoot = rawArgs[index + 1] ?? null
index += 1
continue
}
nextArgs.push(arg)
}
if (!appRoot) {
throw new Error('Missing --app-root for claude-cli sidecar')
}
return { appRoot, args: nextArgs }
}

View File

@ -1,44 +0,0 @@
/**
* Claude Code server sidecar
*
* src/server specifier dynamic import
* `bun build --compile`
* src/ node_modules/
*
* process.env / process.argv await import
* - src/server/index.ts process.argv --app-root
* - preload.ts MACRO server
*/
const { appRoot, args } = parseLauncherArgs()
// 维持 conversationService → CLI 子进程的兼容契约CLI sidecar 仍然
// 接受 --app-root 参数,所以 server 这边把它原样透传到环境变量里。
process.env.CLAUDE_APP_ROOT = appRoot
process.argv = [process.argv[0]!, process.argv[1]!, ...args]
await import('../../preload.ts')
const { startServer } = await import('../../src/server/index.ts')
startServer()
function parseLauncherArgs() {
const rawArgs = process.argv.slice(2)
const nextArgs: string[] = []
let appRoot: string | null = process.env.CLAUDE_APP_ROOT ?? null
for (let index = 0; index < rawArgs.length; index++) {
const arg = rawArgs[index]
if (arg === '--app-root') {
appRoot = rawArgs[index + 1] ?? null
index += 1
continue
}
nextArgs.push(arg)
}
if (!appRoot) {
throw new Error('Missing --app-root for claude-server sidecar')
}
return { appRoot, args: nextArgs }
}

View File

@ -105,11 +105,13 @@ fn start_server_sidecar(app: &AppHandle) -> Result<ServerRuntime, String> {
let app_root = resolve_app_root(app)?;
let app_root_arg = app_root.to_string_lossy().to_string();
// 单一合并 sidecar第一个参数选 server / cli 模式。
let sidecar = app
.shell()
.sidecar("claude-server")
.map_err(|err| format!("resolve server sidecar: {err}"))?
.sidecar("claude-sidecar")
.map_err(|err| format!("resolve sidecar: {err}"))?
.args([
"server",
"--app-root",
&app_root_arg,
"--host",

View File

@ -44,8 +44,7 @@
"targets": "all",
"createUpdaterArtifacts": true,
"externalBin": [
"binaries/claude-server",
"binaries/claude-cli"
"binaries/claude-sidecar"
],
"resources": {},
"icon": [

View File

@ -448,19 +448,29 @@ export class ConversationService {
}
private resolveBundledCliPath(): string | null {
// 桌面端 P0+P2 之后只有一个合并的 sidecar 二进制 —— `claude-sidecar`
// 它通过第一个 positional 参数 (server / cli) 选模式。当前进程要么
// 已经是这个 sidecar 自己spawn 子 CLI 时复用同一个文件),要么是
// 旧 dev 模式下走 bin/claude-haha。这里支持两种命名
// - 桌面端 prod build进程名 claude-sidecar*
// - 旧 server-only 二进制向后兼容claude-server*
const execPath = process.execPath
const execName = path.basename(execPath)
if (!execName.startsWith('claude-server')) {
return null
if (execName.startsWith('claude-sidecar')) {
// 复用同一个二进制,调用 cli 模式
return execPath
}
const bundledCliPath = path.join(
path.dirname(execPath),
execName.replace(/^claude-server/, 'claude-cli'),
)
if (execName.startsWith('claude-server')) {
const bundledCliPath = path.join(
path.dirname(execPath),
execName.replace(/^claude-server/, 'claude-cli'),
)
return fs.existsSync(bundledCliPath) ? bundledCliPath : null
}
return fs.existsSync(bundledCliPath) ? bundledCliPath : null
return null
}
private resolveCliArgs(baseArgs: string[]): string[] {
@ -473,9 +483,21 @@ export class ConversationService {
return ['bun', cliCommand, ...baseArgs]
}
const cliBaseName = path.basename(cliCommand)
// 合并 sidecar 模式:第一个参数必须是 'cli',后面跟 --app-root 透传
if (cliBaseName.startsWith('claude-sidecar')) {
const args = ['cli', ...baseArgs]
if (process.env.CLAUDE_APP_ROOT) {
return [cliCommand, 'cli', '--app-root', process.env.CLAUDE_APP_ROOT, ...baseArgs]
}
return [cliCommand, ...args]
}
// 旧两段式 sidecarclaude-cli 二进制需要 --app-root
if (
process.env.CLAUDE_APP_ROOT &&
path.basename(cliCommand).startsWith('claude-cli')
cliBaseName.startsWith('claude-cli')
) {
return [
cliCommand,