Skip to content

Commit f2bc99f

Browse files
acedatacloud-devGermey
authored andcommitted
fix: resolve Claude Code executable outside asar for Electron
In packaged Electron, the SDK's bundled cli.js lives inside app.asar. child_process.spawn('node', ['...app.asar/.../cli.js']) fails with exit code 1 because the child node process has no asar support. Three-part fix: 1. electron-builder.yml: asarUnpack claude-agent-sdk so cli.js is on disk 2. provider.ts: resolveClaudeCodePath() finds unpacked cli.js or global claude binary, passes pathToClaudeCodeExecutable to SDK options 3. electron/main.ts: fixElectronPath() resolves user's full shell PATH (fixes 'spawn node ENOENT' in Electron launched from Dock/Finder) Also adds stderr capture from SDK for better error diagnostics.
1 parent 85bce94 commit f2bc99f

3 files changed

Lines changed: 165 additions & 64 deletions

File tree

electron-builder.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ files:
1111
- node_modules/**
1212
- package.json
1313

14+
# The claude-agent-sdk spawns cli.js via child_process.spawn().
15+
# child processes can't read files inside asar, so unpack it.
16+
asarUnpack:
17+
- node_modules/@anthropic-ai/claude-agent-sdk/**
18+
1419
# Do NOT include the CLI dist/ or src/ in the Electron app
1520
extraFiles: []
1621

electron/main.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
Notification,
1818
shell
1919
} from 'electron';
20+
import { execFileSync } from 'node:child_process';
2021
import { existsSync, readFileSync, readdirSync } from 'node:fs';
2122
import { join, dirname } from 'path';
2223
import { fileURLToPath } from 'url';
@@ -136,6 +137,44 @@ function pushRecentMessage(entry: {
136137
}
137138
}
138139

140+
// ---------------------------------------------------------------------------
141+
// Fix PATH for macOS/Linux — Electron GUI apps inherit a minimal PATH that
142+
// doesn't include user-installed node (nvm, volta, homebrew, fnm, etc.).
143+
// We resolve the real PATH from the user's login shell before anything runs.
144+
// ---------------------------------------------------------------------------
145+
146+
function fixElectronPath(): void {
147+
if (process.platform !== 'darwin' && process.platform !== 'linux') return;
148+
149+
try {
150+
const shell = process.env.SHELL || '/bin/zsh';
151+
const result = execFileSync(shell, ['-ilc', 'echo -n "$PATH"'], {
152+
encoding: 'utf8',
153+
timeout: 5000,
154+
stdio: ['ignore', 'pipe', 'ignore']
155+
}).trim();
156+
if (result) {
157+
process.env.PATH = result;
158+
logger.info('Resolved shell PATH for Electron', { pathLength: result.length });
159+
}
160+
} catch {
161+
// Fallback: prepend common node binary directories
162+
const home = homedir();
163+
const fallbackPaths = [
164+
'/opt/homebrew/bin',
165+
'/usr/local/bin',
166+
`${home}/.volta/bin`,
167+
`${home}/.fnm/aliases/default/bin`
168+
].filter((p) => existsSync(p));
169+
if (fallbackPaths.length) {
170+
process.env.PATH = [...fallbackPaths, process.env.PATH].join(':');
171+
logger.info('Applied fallback PATH entries', { added: fallbackPaths });
172+
}
173+
}
174+
}
175+
176+
fixElectronPath();
177+
139178
// ---------------------------------------------------------------------------
140179
// Single instance lock
141180
// ---------------------------------------------------------------------------

src/claude/provider.ts

Lines changed: 121 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,69 @@ import {
66
type SDKUserMessage,
77
type Options,
88
type CanUseTool,
9-
type PermissionResult,
10-
} from "@anthropic-ai/claude-agent-sdk";
11-
import { logger } from "../logger.js";
9+
type PermissionResult
10+
} from '@anthropic-ai/claude-agent-sdk';
11+
import { existsSync } from 'node:fs';
12+
import { join } from 'node:path';
13+
import { logger } from '../logger.js';
14+
15+
// ---------------------------------------------------------------------------
16+
// Resolve Claude Code executable for packaged Electron
17+
// ---------------------------------------------------------------------------
18+
19+
/**
20+
* In a packaged Electron app the SDK's bundled cli.js lives inside the asar
21+
* archive. child_process.spawn() can't read from asar, so the spawned node
22+
* process fails with exit code 1.
23+
*
24+
* Resolution order:
25+
* 1. asar-unpacked cli.js (electron-builder asarUnpack extracts it)
26+
* 2. Globally installed `claude` native binary (searched via PATH)
27+
* 3. undefined → let the SDK use its default (works in CLI / dev mode)
28+
*/
29+
function resolveClaudeCodePath(): string | undefined {
30+
const isPackagedElectron =
31+
!!process.versions?.electron && !(process as unknown as { defaultApp?: boolean }).defaultApp;
32+
if (!isPackagedElectron) return undefined;
33+
34+
// 1. Try asar-unpacked cli.js
35+
const resourcesPath = (process as unknown as { resourcesPath?: string }).resourcesPath;
36+
if (resourcesPath) {
37+
const unpackedCli = join(
38+
resourcesPath,
39+
'app.asar.unpacked',
40+
'node_modules',
41+
'@anthropic-ai',
42+
'claude-agent-sdk',
43+
'cli.js'
44+
);
45+
if (existsSync(unpackedCli)) {
46+
logger.info('Using asar-unpacked cli.js for Claude SDK', {
47+
path: unpackedCli
48+
});
49+
return unpackedCli;
50+
}
51+
}
52+
53+
// 2. Try globally installed claude binary from PATH
54+
const pathDirs = (process.env.PATH || '').split(':');
55+
for (const dir of pathDirs) {
56+
const candidate = join(dir, 'claude');
57+
try {
58+
if (existsSync(candidate)) {
59+
logger.info('Using global claude binary for SDK', {
60+
path: candidate
61+
});
62+
return candidate;
63+
}
64+
} catch {
65+
// permission error on dir — skip
66+
}
67+
}
68+
69+
logger.warn('Packaged Electron: could not resolve Claude Code executable outside asar');
70+
return undefined;
71+
}
1272

1373
// ---------------------------------------------------------------------------
1474
// Public types
@@ -19,15 +79,12 @@ export interface QueryOptions {
1979
cwd: string;
2080
resume?: string;
2181
model?: string;
22-
permissionMode?: "default" | "acceptEdits" | "plan" | "bypassPermissions";
82+
permissionMode?: 'default' | 'acceptEdits' | 'plan' | 'bypassPermissions';
2383
images?: Array<{
24-
type: "image";
25-
source: { type: "base64"; media_type: string; data: string };
84+
type: 'image';
85+
source: { type: 'base64'; media_type: string; data: string };
2686
}>;
27-
onPermissionRequest?: (
28-
toolName: string,
29-
toolInput: string,
30-
) => Promise<boolean>;
87+
onPermissionRequest?: (toolName: string, toolInput: string) => Promise<boolean>;
3188
}
3289

3390
export interface QueryResult {
@@ -45,18 +102,18 @@ export interface QueryResult {
45102
*/
46103
function extractText(msg: SDKAssistantMessage): string {
47104
const content = msg.message?.content;
48-
if (!Array.isArray(content)) return "";
105+
if (!Array.isArray(content)) return '';
49106
return content
50-
.filter((block: any) => block.type === "text")
51-
.map((block: any) => (block.text as string) ?? "")
52-
.join("");
107+
.filter((block: any) => block.type === 'text')
108+
.map((block: any) => (block.text as string) ?? '')
109+
.join('');
53110
}
54111

55112
/**
56113
* Extract session_id from any SDKMessage that carries one.
57114
*/
58115
function getSessionId(msg: SDKMessage): string | undefined {
59-
if ("session_id" in msg) {
116+
if ('session_id' in msg) {
60117
return (msg as { session_id: string }).session_id;
61118
}
62119
return undefined;
@@ -69,28 +126,28 @@ function getSessionId(msg: SDKMessage): string | undefined {
69126
*/
70127
async function* singleUserMessage(
71128
text: string,
72-
images?: QueryOptions["images"],
129+
images?: QueryOptions['images']
73130
): AsyncGenerator<SDKUserMessage, void, unknown> {
74131
const contentBlocks: Array<{
75132
type: string;
76133
text?: string;
77-
source?: { type: "base64"; media_type: string; data: string };
78-
}> = [{ type: "text", text }];
134+
source?: { type: 'base64'; media_type: string; data: string };
135+
}> = [{ type: 'text', text }];
79136

80137
if (images?.length) {
81138
for (const img of images) {
82-
contentBlocks.push({ type: "image", source: img.source });
139+
contentBlocks.push({ type: 'image', source: img.source });
83140
}
84141
}
85142

86143
const msg: SDKUserMessage = {
87-
type: "user",
88-
session_id: "",
144+
type: 'user',
145+
session_id: '',
89146
parent_tool_use_id: null,
90147
message: {
91-
role: "user",
92-
content: contentBlocks,
93-
},
148+
role: 'user',
149+
content: contentBlocks
150+
}
94151
};
95152

96153
yield msg;
@@ -101,22 +158,14 @@ async function* singleUserMessage(
101158
// ---------------------------------------------------------------------------
102159

103160
export async function claudeQuery(options: QueryOptions): Promise<QueryResult> {
104-
const {
105-
prompt,
106-
cwd,
107-
resume,
108-
model,
109-
permissionMode,
110-
images,
111-
onPermissionRequest,
112-
} = options;
161+
const { prompt, cwd, resume, model, permissionMode, images, onPermissionRequest } = options;
113162

114-
logger.info("Starting Claude query", {
163+
logger.info('Starting Claude query', {
115164
cwd,
116165
model,
117166
permissionMode,
118167
resume: !!resume,
119-
hasImages: !!images?.length,
168+
hasImages: !!images?.length
120169
});
121170

122171
// When images are present we use the multi-content AsyncIterable path;
@@ -130,45 +179,53 @@ export async function claudeQuery(options: QueryOptions): Promise<QueryResult> {
130179
const sdkOptions: Options = {
131180
cwd,
132181
permissionMode,
133-
allowDangerouslySkipPermissions: permissionMode === "bypassPermissions",
134-
settingSources: ["user", "project"],
182+
allowDangerouslySkipPermissions: permissionMode === 'bypassPermissions',
183+
settingSources: ['user', 'project'],
184+
stderr: (msg: string) => logger.debug('Claude SDK stderr', { msg })
135185
};
136186

187+
// In packaged Electron the SDK's bundled cli.js is inside asar and
188+
// can't be spawned by a child node process. Resolve an alternative.
189+
const claudeCodePath = resolveClaudeCodePath();
190+
if (claudeCodePath) {
191+
sdkOptions.pathToClaudeCodeExecutable = claudeCodePath;
192+
}
193+
137194
if (model) sdkOptions.model = model;
138195
if (resume) sdkOptions.resume = resume;
139196

140197
// Permission callback — bridges the SDK's CanUseTool to our simpler handler.
141198
if (onPermissionRequest) {
142199
const canUseTool: CanUseTool = async (
143200
toolName: string,
144-
input: Record<string, unknown>,
201+
input: Record<string, unknown>
145202
): Promise<PermissionResult> => {
146203
const inputStr = JSON.stringify(input);
147-
logger.info("Permission request from SDK", { toolName });
204+
logger.info('Permission request from SDK', { toolName });
148205
try {
149206
const allowed = await onPermissionRequest(toolName, inputStr);
150207
if (allowed) {
151-
return { behavior: "allow", updatedInput: input };
208+
return { behavior: 'allow', updatedInput: input };
152209
}
153210
return {
154-
behavior: "deny",
155-
message: "Permission denied by user.",
156-
interrupt: true,
211+
behavior: 'deny',
212+
message: 'Permission denied by user.',
213+
interrupt: true
157214
};
158215
} catch (err) {
159-
logger.error("Permission handler error", { toolName, err });
216+
logger.error('Permission handler error', { toolName, err });
160217
return {
161-
behavior: "deny",
162-
message: "Permission check failed.",
163-
interrupt: true,
218+
behavior: 'deny',
219+
message: 'Permission check failed.',
220+
interrupt: true
164221
};
165222
}
166223
};
167224
sdkOptions.canUseTool = canUseTool;
168225
}
169226

170227
// --- Execute query & accumulate output ---
171-
let sessionId = "";
228+
let sessionId = '';
172229
const textParts: string[] = [];
173230
let errorMessage: string | undefined;
174231

@@ -180,31 +237,31 @@ export async function claudeQuery(options: QueryOptions): Promise<QueryResult> {
180237
if (sid) sessionId = sid;
181238

182239
switch (message.type) {
183-
case "assistant": {
240+
case 'assistant': {
184241
const text = extractText(message as SDKAssistantMessage);
185242
if (text) textParts.push(text);
186243
break;
187244
}
188-
case "result": {
245+
case 'result': {
189246
const rm = message as SDKResultMessage;
190-
if (rm.subtype === "success" && "result" in rm) {
247+
if (rm.subtype === 'success' && 'result' in rm) {
191248
// The SDK result message carries the final result string.
192249
// Append only when it adds content not yet seen.
193250
if (rm.result) {
194-
const combined = textParts.join("");
251+
const combined = textParts.join('');
195252
if (!combined.includes(rm.result)) {
196253
textParts.push(rm.result);
197254
}
198255
}
199-
} else if ("errors" in rm && rm.errors.length > 0) {
200-
errorMessage = rm.errors.join("; ");
201-
logger.error("SDK returned error result", { errors: rm.errors });
256+
} else if ('errors' in rm && rm.errors.length > 0) {
257+
errorMessage = rm.errors.join('; ');
258+
logger.error('SDK returned error result', { errors: rm.errors });
202259
}
203260
break;
204261
}
205-
case "system": {
206-
logger.debug("SDK system message", {
207-
subtype: (message as { subtype?: string }).subtype,
262+
case 'system': {
263+
logger.debug('SDK system message', {
264+
subtype: (message as { subtype?: string }).subtype
208265
});
209266
break;
210267
}
@@ -215,24 +272,24 @@ export async function claudeQuery(options: QueryOptions): Promise<QueryResult> {
215272
}
216273
} catch (err: unknown) {
217274
errorMessage = err instanceof Error ? err.message : String(err);
218-
logger.error("Claude query threw", { error: errorMessage });
275+
logger.error('Claude query threw', { error: errorMessage });
219276
}
220277

221-
const fullText = textParts.join("\n").trim();
278+
const fullText = textParts.join('\n').trim();
222279

223280
if (!fullText && !errorMessage) {
224-
errorMessage = "Claude returned an empty response.";
281+
errorMessage = 'Claude returned an empty response.';
225282
}
226283

227-
logger.info("Claude query completed", {
284+
logger.info('Claude query completed', {
228285
sessionId,
229286
textLength: fullText.length,
230-
hasError: !!errorMessage,
287+
hasError: !!errorMessage
231288
});
232289

233290
return {
234291
text: fullText,
235292
sessionId,
236-
error: errorMessage,
293+
error: errorMessage
237294
};
238295
}

0 commit comments

Comments
 (0)