Skip to content

Commit 0ffa648

Browse files
fazxesclaude
andcommitted
feat(build): bundle Claude CLI and agent-bridge as standalone binaries
- Add build script that patches Claude CLI to embed yoga.wasm as base64 - Compile agent-bridge and Claude CLI with Bun for standalone execution - Update Tauri config to bundle binaries via externalBin - Add macOS Entitlements.plist for process spawning permissions - Pass CLAUDE_CLI_PATH env var from Rust to sidecar - Add PATH setup for apps launched from Finder - Resolve sidecar path for both development and production - Migrate ESLint config to use defineConfig from eslint/config This makes the app self-contained - users don't need to install Claude CLI separately. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 4e396ae commit 0ffa648

12 files changed

Lines changed: 266 additions & 26 deletions

File tree

‎agent-bridge/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
},
1414
"dependencies": {
1515
"@anthropic-ai/claude-agent-sdk": "^0.1.76",
16+
"@anthropic-ai/claude-code": "^1.0.28",
1617
"@anthropic-ai/sdk": "^0.39.0",
1718
"@modelcontextprotocol/sdk": "^1.0.0"
1819
},
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Build script for Claude CLI binary
4+
*
5+
* This script patches the Claude Code CLI to embed yoga.wasm as base64,
6+
* then compiles it with Bun to create a standalone binary.
7+
*/
8+
9+
import { readFileSync, writeFileSync, mkdirSync } from 'fs';
10+
import { execSync } from 'child_process';
11+
import { dirname, join } from 'path';
12+
import { fileURLToPath } from 'url';
13+
14+
const __dirname = dirname(fileURLToPath(import.meta.url));
15+
const agentBridgeDir = dirname(__dirname);
16+
const binariesDir = join(agentBridgeDir, '..', 'src-tauri', 'binaries');
17+
18+
// Ensure binaries directory exists
19+
mkdirSync(binariesDir, { recursive: true });
20+
21+
console.log('📦 Building Claude CLI binary with embedded yoga.wasm...');
22+
23+
// Read the original CLI
24+
const cliPath = join(agentBridgeDir, 'node_modules', '@anthropic-ai', 'claude-code', 'cli.js');
25+
const wasmPath = join(agentBridgeDir, 'node_modules', '@anthropic-ai', 'claude-code', 'yoga.wasm');
26+
27+
console.log(' Reading CLI source...');
28+
const cli = readFileSync(cliPath, 'utf8');
29+
30+
// Read yoga.wasm and convert to base64
31+
console.log(' Embedding yoga.wasm as base64...');
32+
const wasmBuffer = readFileSync(wasmPath);
33+
const wasmBase64 = wasmBuffer.toString('base64');
34+
35+
// Replace the yoga.wasm loading pattern
36+
// Original: await idQ(ndQ(import.meta.url).resolve("./yoga.wasm"))
37+
// New: Buffer.from("base64data","base64")
38+
const patched = cli.replace(
39+
'await idQ(ndQ(import.meta.url).resolve("./yoga.wasm"))',
40+
`Buffer.from("${wasmBase64}","base64")`
41+
);
42+
43+
if (patched === cli) {
44+
console.error('❌ Failed to patch yoga.wasm loading - pattern not found');
45+
process.exit(1);
46+
}
47+
48+
// Write patched file to temp location
49+
const patchedPath = '/tmp/claude-cli-patched.js';
50+
writeFileSync(patchedPath, patched);
51+
console.log(` Patched CLI size: ${patched.length} bytes (was ${cli.length})`);
52+
53+
// Compile with Bun
54+
const outputPath = join(binariesDir, 'claude-aarch64-apple-darwin');
55+
console.log(' Compiling with Bun...');
56+
57+
try {
58+
execSync(`bun build --compile --minify --target=bun-darwin-arm64 ${patchedPath} --outfile ${outputPath}`, {
59+
stdio: 'inherit',
60+
cwd: agentBridgeDir
61+
});
62+
console.log('✅ Claude CLI binary built successfully');
63+
} catch (error) {
64+
console.error('❌ Failed to compile Claude CLI:', error.message);
65+
process.exit(1);
66+
}

‎agent-bridge/src/agent.ts‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
* Claude Agent SDK integration for Snowflake (TypeScript).
33
*/
44

5+
import { existsSync } from 'fs';
6+
57
import { query } from '@anthropic-ai/claude-agent-sdk';
68

79
import { ClaudeCredentials } from './credentials.js';
@@ -335,11 +337,37 @@ export class OrbitAgent {
335337
return this.permissionManager;
336338
}
337339

340+
/**
341+
* Find the Claude Code executable path.
342+
* Priority:
343+
* 1. Bundled Claude CLI (production - same dir as agent-bridge)
344+
* 2. SDK's built-in executable (development)
345+
* 3. System-installed Claude CLI (fallback)
346+
*/
347+
private _findClaudeExecutable(): string | undefined {
348+
// Check if CLAUDE_CLI_PATH env var is set (passed by Tauri when spawning sidecar)
349+
const envClaudePath = process.env.CLAUDE_CLI_PATH;
350+
if (envClaudePath !== undefined && envClaudePath !== '') {
351+
if (existsSync(envClaudePath)) {
352+
logger.info({ path: envClaudePath }, 'Found Claude CLI from CLAUDE_CLI_PATH env var');
353+
return envClaudePath;
354+
}
355+
logger.warn({ path: envClaudePath }, 'CLAUDE_CLI_PATH set but file not found');
356+
}
357+
358+
// No env var means we're likely in development mode
359+
// Let SDK use its built-in executable
360+
logger.info('No CLAUDE_CLI_PATH env var - SDK will use built-in executable');
361+
return undefined;
362+
}
363+
338364
private _createOptions(): Options {
339365
/**
340366
* Create Claude agent options with full Claude Code capabilities.
341367
*/
342368
const options: Options = {
369+
// Explicitly set Claude Code CLI path for bundled environments (Bun compile)
370+
pathToClaudeCodeExecutable: this._findClaudeExecutable(),
343371
// Use Claude Code's official system prompt with browser automation docs
344372
systemPrompt: {
345373
type: 'preset' as const,

‎agent-bridge/src/index.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,30 @@
44
* Bridges Rust/Tauri backend with Claude Agent SDK via stdin/stdout JSON IPC
55
*/
66

7+
// Ensure common binary paths are in PATH (important for production apps launched from Finder)
8+
const homeDir = process.env.HOME ?? '';
9+
const commonPaths = [
10+
'/opt/homebrew/bin', // macOS Homebrew (Apple Silicon)
11+
'/usr/local/bin', // macOS Homebrew (Intel) / Linux
12+
'/usr/bin',
13+
'/bin',
14+
...(homeDir !== ''
15+
? [
16+
`${homeDir}/.local/bin`, // User local
17+
`${homeDir}/.npm-global/bin`, // npm global
18+
`${homeDir}/.bun/bin`, // Bun
19+
]
20+
: []),
21+
];
22+
const currentPath = process.env.PATH ?? '';
23+
const pathSet = new Set(currentPath.split(':'));
24+
for (const p of commonPaths) {
25+
if (!pathSet.has(p)) {
26+
pathSet.add(p);
27+
}
28+
}
29+
process.env.PATH = Array.from(pathSet).join(':');
30+
731
import * as readline from 'readline';
832

933
import {

‎eslint.config.ts‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
import eslint from '@eslint/js';
2-
import tseslint from 'typescript-eslint';
2+
import { defineConfig } from 'eslint/config';
3+
import importPlugin from 'eslint-plugin-import-x';
34
import reactPlugin from 'eslint-plugin-react';
45
import reactHooksPlugin from 'eslint-plugin-react-hooks';
5-
import importPlugin from 'eslint-plugin-import-x';
6+
import tseslint from 'typescript-eslint';
67

7-
export default tseslint.config(
8+
export default defineConfig(
9+
{
10+
ignores: ['dist/', 'node_modules/', '*.config.js', '*.config.mjs', '**/scripts/'],
11+
},
812
eslint.configs.recommended,
9-
...tseslint.configs.strictTypeChecked,
10-
...tseslint.configs.stylisticTypeChecked,
13+
tseslint.configs.strictTypeChecked,
14+
tseslint.configs.stylisticTypeChecked,
1115
{
1216
languageOptions: {
1317
parserOptions: {
@@ -81,8 +85,5 @@ export default tseslint.config(
8185
'no-console': ['error', { allow: ['warn', 'error'] }],
8286
eqeqeq: ['error', 'always'],
8387
},
84-
},
85-
{
86-
ignores: ['dist/', 'node_modules/', '*.config.js', '*.config.mjs'],
8788
}
8889
);

‎package.json‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44
"version": "0.0.1",
55
"type": "module",
66
"scripts": {
7-
"dev": "SNOWFLAKE_LOG_MODE=dev tauri dev",
8-
"dev:debug": "SNOWFLAKE_LOG_MODE=debug tauri dev",
9-
"dev:quiet": "SNOWFLAKE_LOG_MODE=prod tauri dev",
7+
"dev": "pnpm build:sidecar && SNOWFLAKE_LOG_MODE=dev tauri dev",
8+
"dev:debug": "pnpm build:sidecar && SNOWFLAKE_LOG_MODE=debug tauri dev",
9+
"dev:quiet": "pnpm build:sidecar && SNOWFLAKE_LOG_MODE=prod tauri dev",
1010
"dev:web": "vite",
11-
"build": "tauri build",
12-
"build:debug": "tauri build --debug",
11+
"build": "pnpm build:sidecar && tauri build",
12+
"build:debug": "pnpm build:sidecar && tauri build --debug",
1313
"build:frontend": "tsc --noEmit && vite build",
14+
"build:sidecar": "mkdir -p src-tauri/binaries && cd agent-bridge && bun build --compile --minify --target=bun-darwin-arm64 src/index.ts --outfile ../src-tauri/binaries/agent-bridge-aarch64-apple-darwin && node scripts/build-claude-cli.mjs",
1415
"preview": "vite preview",
1516
"typecheck": "tsc --noEmit",
1617
"lint": "eslint src agent-bridge/src --max-warnings=0",

‎pnpm-lock.yaml‎

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎src-tauri/.gitignore‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,6 @@
22
# will have compiled files and executables
33
/target/
44
/gen/schemas
5+
6+
# Compiled sidecar binaries (built by build script)
7+
/binaries/

‎src-tauri/Entitlements.plist‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<plist version="1.0">
4+
<dict>
5+
<!-- Allow spawning child processes (for agent-bridge and claude CLI) -->
6+
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
7+
<true/>
8+
9+
<!-- Allow spawning helper processes -->
10+
<key>com.apple.security.cs.disable-library-validation</key>
11+
<true/>
12+
13+
<!-- Allow inheriting environment (for PATH) -->
14+
<key>com.apple.security.inherit</key>
15+
<true/>
16+
17+
<!-- Network access for API calls -->
18+
<key>com.apple.security.network.client</key>
19+
<true/>
20+
21+
<!-- File access for workspace -->
22+
<key>com.apple.security.files.user-selected.read-write</key>
23+
<true/>
24+
25+
<!-- Allow accessing user's home directory -->
26+
<key>com.apple.security.files.downloads.read-write</key>
27+
<true/>
28+
</dict>
29+
</plist>

‎src-tauri/src/agent/bridge.rs‎

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
55
use std::fmt;
66
use std::io::{BufRead as _, BufReader, Write as _};
7+
use std::path::Path;
78
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
89
use std::result;
910
use std::sync::Arc;
@@ -84,21 +85,31 @@ impl AgentBridge {
8485
/// # Errors
8586
///
8687
/// Returns an error if the sidecar cannot be spawned or doesn't become ready.
87-
pub fn spawn(&mut self, node_script_path: &str) -> Result<()> {
88+
pub fn spawn(&mut self, sidecar_path: &str) -> Result<()> {
8889
if self.child.is_some() {
8990
return Ok(()); // Already running
9091
}
9192

92-
log::info!("Spawning agent bridge sidecar: {node_script_path}");
93+
log::info!("Spawning agent bridge sidecar: {sidecar_path}");
9394

94-
// Spawn the Node.js process
95-
let mut child = Command::new("node")
96-
.arg(node_script_path)
95+
// Derive claude binary path from sidecar path (same directory)
96+
let sidecar_dir = Path::new(sidecar_path)
97+
.parent()
98+
.map(|p| p.to_string_lossy().to_string())
99+
.unwrap_or_default();
100+
let claude_path = format!("{sidecar_dir}/claude");
101+
102+
log::info!("Claude CLI path: {claude_path}");
103+
104+
// Spawn the compiled sidecar binary directly
105+
// Pass CLAUDE_CLI_PATH env var so the sidecar can find the bundled claude binary
106+
let mut child = Command::new(sidecar_path)
107+
.env("CLAUDE_CLI_PATH", &claude_path)
97108
.stdin(Stdio::piped())
98109
.stdout(Stdio::piped())
99110
.stderr(Stdio::inherit()) // Let stderr go to parent's stderr for debugging
100111
.spawn()
101-
.map_err(|e| BridgeError::SpawnError(e.to_string()))?;
112+
.map_err(|e| BridgeError::SpawnError(format!("{e} (path: {sidecar_path})")))?;
102113

103114
// Take ownership of stdin
104115
let stdin = child

0 commit comments

Comments
 (0)