From f40695909463bcbade6fd710195482cbafd92cdf Mon Sep 17 00:00:00 2001 From: Adam Slaker <51129804+aslaker@users.noreply.github.com> Date: Wed, 7 Jan 2026 00:13:25 -0600 Subject: [PATCH] fix(startup): prevent app freeze by making Claude CLI detection non-blocking (#680 regression) (#720) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: convert Claude CLI detection to async to prevent main process freeze PR #680 introduced synchronous execFileSync calls for Claude CLI detection. When terminal sessions with Claude mode are restored on startup, these blocking calls freeze the Electron main process for 1-3 seconds. Changes: - Add async versions: getAugmentedEnvAsync(), getToolPathAsync(), getClaudeCliInvocationAsync(), invokeClaudeAsync(), resumeClaudeAsync() - Use caching to avoid repeated subprocess calls - Pre-warm CLI cache at startup with setImmediate() for non-blocking detection - Fix ENOWORKSPACES npm error by running npm commands from home directory The sync versions are preserved for backward compatibility but now include warnings in their JSDoc comments recommending the async alternatives. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 Signed-off-by: aslaker <51129804+aslaker@users.noreply.github.com> * refactor: extract shared helpers to reduce sync/async duplication Address PR review feedback by: - Extract pure helper functions for Claude CLI detection: - getClaudeDetectionPaths(): returns platform-specific candidate paths - sortNvmVersionDirs(): sorts NVM versions (newest first) - buildClaudeDetectionResult(): builds detection result from validation - Extract pure helper functions for Claude invocation: - buildClaudeShellCommand(): builds shell command for all methods - finalizeClaudeInvoke(): consolidates post-invocation logic - Add .catch() error handling for all async promise calls - Replace sync fs calls with async versions in detectClaudeAsync - Replace writeFileSync with fsPromises.writeFile in invokeClaudeAsync - Add 24 new unit tests for helper functions - Fix env-handlers tests to use async mock with flushPromises() - Fix claude-integration-handler tests with os.tmpdir() mock 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix: address PR review comments for async CLI detection - Fix TOCTOU race condition in profile-storage.ts by removing existence check before readFile (Comment #7) - Add semver validation regex to sortNvmVersionDirs to filter malformed version strings (Comment #5) - Refactor buildClaudeShellCommand to use discriminated union type for better type safety (Comment #6) - Add async validation/detection methods for Python, Git, and GitHub CLI with proper timeout handling (Comment #3) - Extract shared path-building helpers (getExpandedPlatformPaths, buildPathsToAdd) to reduce sync/async duplication (Comment #4) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix: add env parameter to async CLI validation and pre-warm all tools - Add `env: await getAugmentedEnvAsync()` to validateClaudeAsync, validatePythonAsync, validateGitAsync, and validateGitHubCLIAsync to prevent sync PATH resolution blocking the main thread - Pre-warm all commonly used CLI tools (claude, git, gh, python) instead of just claude to avoid sync blocking on first use Fixes mouse hover freeze on macOS where the app would hang infinitely when the mouse entered the window. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix: address PR review comments for async Windows helpers and profile deduplication CMT-001 [MEDIUM]: detectGitAsync now uses fully async Windows helpers - Add getWindowsExecutablePathsAsync using fs.promises.access - Add findWindowsExecutableViaWhereAsync using promisified execFile - Update detectGitAsync to use async helpers instead of sync versions - Prevents blocking Electron main process on Windows CMT-002 [LOW]: Extract shared profile parsing logic - Add parseAndMigrateProfileData helper function - Simplifies loadProfileStore and loadProfileStoreAsync - Reduces code duplication for version migration and date parsing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix: cast through unknown to satisfy TypeScript strict type checking The direct cast from Record to ProfileStoreData fails TypeScript's overlap check. Cast through unknown first to allow the intentional type assertion. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 * fix: address PR review comments for async Windows helpers and profile deduplication Address AndyMik90's Auto Claude PR Review comments: - [NEW-002] Add missing --location=global flag to async npm prefix detection in getNpmGlobalPrefixAsync (env-utils.ts line 292) to match sync version and prevent ENOWORKSPACES errors in monorepos - [NEW-001/NEW-005] Update resumeClaudeAsync to match sync resumeClaude behavior: always use --continue, clear claudeSessionId to prevent stale IDs, and add deprecation warning for sessionId parameter - [NEW-004] Remove blocking existsSync check in ClaudeProfileManager.initialize() by using idempotent mkdir with recursive:true directly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --------- Signed-off-by: aslaker <51129804+aslaker@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 Co-authored-by: Alex <63423455+AlexMadera@users.noreply.github.com> Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com> --- .../main/__tests__/cli-tool-manager.test.ts | 161 ++- .../__tests__/env-handlers-claude-cli.test.ts | 27 +- apps/frontend/src/main/claude-cli-utils.ts | 32 +- .../src/main/claude-profile-manager.ts | 72 +- .../main/claude-profile/profile-storage.ts | 85 +- apps/frontend/src/main/cli-tool-manager.ts | 965 ++++++++++++++++-- apps/frontend/src/main/env-utils.ts | 278 ++++- apps/frontend/src/main/index.ts | 19 + .../src/main/ipc-handlers/env-handlers.ts | 26 +- .../main/ipc-handlers/settings-handlers.ts | 12 +- .../main/ipc-handlers/terminal-handlers.ts | 14 +- .../claude-integration-handler.test.ts | 200 ++++ .../terminal/claude-integration-handler.ts | 374 +++++-- .../src/main/terminal/terminal-manager.ts | 52 +- apps/frontend/src/main/utils/windows-paths.ts | 113 +- package-lock.json | 184 ++-- 16 files changed, 2265 insertions(+), 349 deletions(-) diff --git a/apps/frontend/src/main/__tests__/cli-tool-manager.test.ts b/apps/frontend/src/main/__tests__/cli-tool-manager.test.ts index 45bfad47..b39c588a 100644 --- a/apps/frontend/src/main/__tests__/cli-tool-manager.test.ts +++ b/apps/frontend/src/main/__tests__/cli-tool-manager.test.ts @@ -8,7 +8,13 @@ import { existsSync, readdirSync } from 'fs'; import os from 'os'; import { execFileSync } from 'child_process'; import { app } from 'electron'; -import { getToolInfo, clearToolCache } from '../cli-tool-manager'; +import { + getToolInfo, + clearToolCache, + getClaudeDetectionPaths, + sortNvmVersionDirs, + buildClaudeDetectionResult +} from '../cli-tool-manager'; // Mock Electron app vi.mock('electron', () => ({ @@ -42,9 +48,10 @@ vi.mock('fs', () => { }; }); -// Mock child_process for execFileSync (used in validation) +// Mock child_process for execFileSync and execFile (used in validation) vi.mock('child_process', () => ({ - execFileSync: vi.fn() + execFileSync: vi.fn(), + execFile: vi.fn() })); // Mock env-utils to avoid PATH augmentation complexity @@ -312,3 +319,151 @@ describe('cli-tool-manager - Claude CLI NVM detection', () => { }); }); }); + +/** + * Unit tests for helper functions + */ +describe('cli-tool-manager - Helper Functions', () => { + describe('getClaudeDetectionPaths', () => { + it('should return homebrew paths on macOS', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + writable: true + }); + + const paths = getClaudeDetectionPaths('/Users/test'); + + expect(paths.homebrewPaths).toContain('/opt/homebrew/bin/claude'); + expect(paths.homebrewPaths).toContain('/usr/local/bin/claude'); + }); + + it('should return Windows paths on win32', () => { + Object.defineProperty(process, 'platform', { + value: 'win32', + writable: true + }); + + const paths = getClaudeDetectionPaths('C:\\Users\\test'); + + // Windows paths should include AppData and Program Files + expect(paths.platformPaths.some(p => p.includes('AppData'))).toBe(true); + expect(paths.platformPaths.some(p => p.includes('Program Files'))).toBe(true); + }); + + it('should return Unix paths on Linux', () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + writable: true + }); + + const paths = getClaudeDetectionPaths('/home/test'); + + expect(paths.platformPaths.some(p => p.includes('.local/bin/claude'))).toBe(true); + expect(paths.platformPaths.some(p => p.includes('bin/claude'))).toBe(true); + }); + + it('should return correct NVM versions directory', () => { + const paths = getClaudeDetectionPaths('/home/test'); + + expect(paths.nvmVersionsDir).toBe('/home/test/.nvm/versions/node'); + }); + }); + + describe('sortNvmVersionDirs', () => { + it('should sort versions in descending order (newest first)', () => { + const entries = [ + { name: 'v18.20.0', isDirectory: () => true }, + { name: 'v22.17.0', isDirectory: () => true }, + { name: 'v20.11.0', isDirectory: () => true } + ]; + + const sorted = sortNvmVersionDirs(entries); + + expect(sorted).toEqual(['v22.17.0', 'v20.11.0', 'v18.20.0']); + }); + + it('should filter out non-version directories', () => { + const entries = [ + { name: 'v20.11.0', isDirectory: () => true }, + { name: '.DS_Store', isDirectory: () => false }, + { name: 'node_modules', isDirectory: () => true }, + { name: 'current', isDirectory: () => true }, + { name: 'v22.17.0', isDirectory: () => true } + ]; + + const sorted = sortNvmVersionDirs(entries); + + expect(sorted).toEqual(['v22.17.0', 'v20.11.0']); + expect(sorted).not.toContain('.DS_Store'); + expect(sorted).not.toContain('node_modules'); + expect(sorted).not.toContain('current'); + }); + + it('should return empty array when no valid versions', () => { + const entries = [ + { name: 'current', isDirectory: () => true }, + { name: 'system', isDirectory: () => true } + ]; + + const sorted = sortNvmVersionDirs(entries); + + expect(sorted).toEqual([]); + }); + + it('should handle single entry', () => { + const entries = [{ name: 'v20.11.0', isDirectory: () => true }]; + + const sorted = sortNvmVersionDirs(entries); + + expect(sorted).toEqual(['v20.11.0']); + }); + + it('should handle empty array', () => { + const sorted = sortNvmVersionDirs([]); + + expect(sorted).toEqual([]); + }); + }); + + describe('buildClaudeDetectionResult', () => { + it('should return null when validation fails', () => { + const result = buildClaudeDetectionResult( + '/path/to/claude', + { valid: false, message: 'Invalid CLI' }, + 'nvm', + 'Found via NVM' + ); + + expect(result).toBeNull(); + }); + + it('should return proper result when validation succeeds', () => { + const result = buildClaudeDetectionResult( + '/path/to/claude', + { valid: true, version: '1.0.0', message: 'Valid' }, + 'nvm', + 'Found via NVM' + ); + + expect(result).not.toBeNull(); + expect(result?.found).toBe(true); + expect(result?.path).toBe('/path/to/claude'); + expect(result?.version).toBe('1.0.0'); + expect(result?.source).toBe('nvm'); + expect(result?.message).toContain('Found via NVM'); + expect(result?.message).toContain('/path/to/claude'); + }); + + it('should include path in message', () => { + const result = buildClaudeDetectionResult( + '/home/user/.nvm/versions/node/v22.17.0/bin/claude', + { valid: true, version: '2.0.0', message: 'OK' }, + 'nvm', + 'Detected Claude CLI' + ); + + expect(result?.message).toContain('Detected Claude CLI'); + expect(result?.message).toContain('/home/user/.nvm/versions/node/v22.17.0/bin/claude'); + }); + }); +}); diff --git a/apps/frontend/src/main/__tests__/env-handlers-claude-cli.test.ts b/apps/frontend/src/main/__tests__/env-handlers-claude-cli.test.ts index a0d3caf3..bbcbdc35 100644 --- a/apps/frontend/src/main/__tests__/env-handlers-claude-cli.test.ts +++ b/apps/frontend/src/main/__tests__/env-handlers-claude-cli.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { IPC_CHANNELS } from '../../shared/constants'; const { mockGetClaudeCliInvocation, + mockGetClaudeCliInvocationAsync, mockGetProject, spawnMock, mockIpcMain, @@ -22,6 +23,7 @@ const { return { mockGetClaudeCliInvocation: vi.fn(), + mockGetClaudeCliInvocationAsync: vi.fn(), mockGetProject: vi.fn(), spawnMock: vi.fn(), mockIpcMain: ipcMain, @@ -30,6 +32,7 @@ const { vi.mock('../claude-cli-utils', () => ({ getClaudeCliInvocation: mockGetClaudeCliInvocation, + getClaudeCliInvocationAsync: mockGetClaudeCliInvocationAsync, })); vi.mock('../project-store', () => ({ @@ -64,9 +67,15 @@ function createProc(): EventEmitter & { stdout?: EventEmitter; stderr?: EventEmi return proc; } +// Helper to flush all pending promises (needed for async mock resolution) +function flushPromises(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)); +} + describe('env-handlers Claude CLI usage', () => { beforeEach(() => { mockGetClaudeCliInvocation.mockReset(); + mockGetClaudeCliInvocationAsync.mockReset(); mockGetProject.mockReset(); spawnMock.mockReset(); }); @@ -74,7 +83,7 @@ describe('env-handlers Claude CLI usage', () => { it('uses resolved Claude CLI path/env for auth checks', async () => { const claudeEnv = { PATH: '/opt/claude/bin:/usr/bin' }; const command = '/opt/claude/bin/claude'; - mockGetClaudeCliInvocation.mockReturnValue({ + mockGetClaudeCliInvocationAsync.mockResolvedValue({ command, env: claudeEnv, }); @@ -94,6 +103,8 @@ describe('env-handlers Claude CLI usage', () => { } const resultPromise = handler({}, 'p1'); + // Wait for async CLI resolution before checking spawn + await flushPromises(); expect(spawnMock).toHaveBeenCalledTimes(1); expect(spawnMock).toHaveBeenCalledWith( command, @@ -120,7 +131,7 @@ describe('env-handlers Claude CLI usage', () => { it('uses resolved Claude CLI path/env for setup-token', async () => { const claudeEnv = { PATH: '/opt/claude/bin:/usr/bin' }; const command = '/opt/claude/bin/claude'; - mockGetClaudeCliInvocation.mockReturnValue({ + mockGetClaudeCliInvocationAsync.mockResolvedValue({ command, env: claudeEnv, }); @@ -136,6 +147,8 @@ describe('env-handlers Claude CLI usage', () => { } const resultPromise = handler({}, 'p2'); + // Wait for async CLI resolution before checking spawn + await flushPromises(); expect(spawnMock).toHaveBeenCalledWith( command, ['setup-token'], @@ -153,9 +166,7 @@ describe('env-handlers Claude CLI usage', () => { }); it('returns an error when Claude CLI resolution throws', async () => { - mockGetClaudeCliInvocation.mockImplementation(() => { - throw new Error('Claude CLI exploded'); - }); + mockGetClaudeCliInvocationAsync.mockRejectedValue(new Error('Claude CLI exploded')); mockGetProject.mockReturnValue({ id: 'p3', path: '/tmp/project' }); registerEnvHandlers(() => null); @@ -171,7 +182,7 @@ describe('env-handlers Claude CLI usage', () => { }); it('returns an error when Claude CLI command is missing', async () => { - mockGetClaudeCliInvocation.mockReturnValue({ command: '', env: {} }); + mockGetClaudeCliInvocationAsync.mockResolvedValue({ command: '', env: {} }); mockGetProject.mockReturnValue({ id: 'p4', path: '/tmp/project' }); registerEnvHandlers(() => null); @@ -189,7 +200,7 @@ describe('env-handlers Claude CLI usage', () => { it('returns an error when Claude CLI exits with a non-zero code', async () => { const claudeEnv = { PATH: '/opt/claude/bin:/usr/bin' }; const command = '/opt/claude/bin/claude'; - mockGetClaudeCliInvocation.mockReturnValue({ + mockGetClaudeCliInvocationAsync.mockResolvedValue({ command, env: claudeEnv, }); @@ -205,6 +216,8 @@ describe('env-handlers Claude CLI usage', () => { } const resultPromise = handler({}, 'p5'); + // Wait for async CLI resolution before checking spawn + await flushPromises(); expect(spawnMock).toHaveBeenCalledWith( command, ['--version'], diff --git a/apps/frontend/src/main/claude-cli-utils.ts b/apps/frontend/src/main/claude-cli-utils.ts index 3d83a555..49a0c49c 100644 --- a/apps/frontend/src/main/claude-cli-utils.ts +++ b/apps/frontend/src/main/claude-cli-utils.ts @@ -1,6 +1,6 @@ import path from 'path'; -import { getAugmentedEnv } from './env-utils'; -import { getToolPath } from './cli-tool-manager'; +import { getAugmentedEnv, getAugmentedEnvAsync } from './env-utils'; +import { getToolPath, getToolPathAsync } from './cli-tool-manager'; export type ClaudeCliInvocation = { command: string; @@ -37,6 +37,9 @@ function ensureCommandDirInPath(command: string, env: Record): R /** * Returns the Claude CLI command path and an environment with PATH updated to include the CLI directory. + * + * WARNING: This function uses synchronous subprocess calls that block the main process. + * For use in Electron main process, prefer getClaudeCliInvocationAsync() instead. */ export function getClaudeCliInvocation(): ClaudeCliInvocation { const command = getToolPath('claude'); @@ -47,3 +50,28 @@ export function getClaudeCliInvocation(): ClaudeCliInvocation { env: ensureCommandDirInPath(command, env), }; } + +/** + * Returns the Claude CLI command path and environment asynchronously (non-blocking). + * + * Safe to call from Electron main process without blocking the event loop. + * Uses cached values if available for instant response. + * + * @example + * ```typescript + * const { command, env } = await getClaudeCliInvocationAsync(); + * spawn(command, ['--version'], { env }); + * ``` + */ +export async function getClaudeCliInvocationAsync(): Promise { + // Run both detections in parallel for efficiency + const [command, env] = await Promise.all([ + getToolPathAsync('claude'), + getAugmentedEnvAsync(), + ]); + + return { + command, + env: ensureCommandDirInPath(command, env), + }; +} diff --git a/apps/frontend/src/main/claude-profile-manager.ts b/apps/frontend/src/main/claude-profile-manager.ts index 0f9c88f6..f64ef42d 100644 --- a/apps/frontend/src/main/claude-profile-manager.ts +++ b/apps/frontend/src/main/claude-profile-manager.ts @@ -13,7 +13,7 @@ import { app } from 'electron'; import { join } from 'path'; -import { existsSync, mkdirSync } from 'fs'; +import { mkdir } from 'fs/promises'; import type { ClaudeProfile, ClaudeProfileSettings, @@ -32,6 +32,7 @@ import { } from './claude-profile/rate-limit-manager'; import { loadProfileStore, + loadProfileStoreAsync, saveProfileStore, ProfileStoreData, DEFAULT_AUTO_SWITCH_SETTINGS @@ -57,19 +58,45 @@ import { */ export class ClaudeProfileManager { private storePath: string; + private configDir: string; private data: ProfileStoreData; + private initialized: boolean = false; constructor() { - const configDir = join(app.getPath('userData'), 'config'); - this.storePath = join(configDir, 'claude-profiles.json'); + this.configDir = join(app.getPath('userData'), 'config'); + this.storePath = join(this.configDir, 'claude-profiles.json'); - // Ensure directory exists - if (!existsSync(configDir)) { - mkdirSync(configDir, { recursive: true }); + // DON'T do file I/O here - defer to async initialize() + // Start with default data until initialized + this.data = this.createDefaultData(); + } + + /** + * Initialize the profile manager asynchronously (non-blocking) + * This should be called at app startup via initializeClaudeProfileManager() + */ + async initialize(): Promise { + if (this.initialized) return; + + // Ensure directory exists (async) - mkdir with recursive:true is idempotent + await mkdir(this.configDir, { recursive: true }); + + // Load existing data asynchronously + const loadedData = await loadProfileStoreAsync(this.storePath); + if (loadedData) { + this.data = loadedData; } + // else: keep the default data from constructor - // Load existing data or initialize with default profile - this.data = this.load(); + this.initialized = true; + console.warn('[ClaudeProfileManager] Initialized asynchronously'); + } + + /** + * Check if the profile manager has been initialized + */ + isInitialized(): boolean { + return this.initialized; } /** @@ -522,11 +549,13 @@ export class ClaudeProfileManager { } } -// Singleton instance +// Singleton instance and initialization promise let profileManager: ClaudeProfileManager | null = null; +let initPromise: Promise | null = null; /** * Get the singleton Claude profile manager instance + * Note: For async contexts, prefer initializeClaudeProfileManager() to ensure initialization */ export function getClaudeProfileManager(): ClaudeProfileManager { if (!profileManager) { @@ -534,3 +563,28 @@ export function getClaudeProfileManager(): ClaudeProfileManager { } return profileManager; } + +/** + * Initialize and get the singleton Claude profile manager instance (async) + * This ensures the profile manager is fully initialized before use. + * Uses promise caching to prevent concurrent initialization. + */ +export async function initializeClaudeProfileManager(): Promise { + if (!profileManager) { + profileManager = new ClaudeProfileManager(); + } + + // If already initialized, return immediately + if (profileManager.isInitialized()) { + return profileManager; + } + + // If initialization is in progress, wait for it (promise caching) + if (!initPromise) { + initPromise = profileManager.initialize().then(() => { + return profileManager!; + }); + } + + return initPromise; +} diff --git a/apps/frontend/src/main/claude-profile/profile-storage.ts b/apps/frontend/src/main/claude-profile/profile-storage.ts index bd5b89c3..a4c825e2 100644 --- a/apps/frontend/src/main/claude-profile/profile-storage.ts +++ b/apps/frontend/src/main/claude-profile/profile-storage.ts @@ -4,6 +4,7 @@ */ import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { readFile } from 'fs/promises'; import type { ClaudeProfile, ClaudeAutoSwitchSettings } from '../../shared/types'; export const STORE_VERSION = 3; // Bumped for encrypted token storage @@ -30,6 +31,42 @@ export interface ProfileStoreData { autoSwitch?: ClaudeAutoSwitchSettings; } +/** + * Parse and migrate profile data from JSON. + * Handles version migration and date parsing. + * Shared helper used by both sync and async loaders. + */ +function parseAndMigrateProfileData(data: Record): ProfileStoreData | null { + // Handle version migration + if (data.version === 1) { + // Migrate v1 to v2: add usage and rateLimitEvents fields + data.version = STORE_VERSION; + data.autoSwitch = DEFAULT_AUTO_SWITCH_SETTINGS; + } + + if (data.version === STORE_VERSION) { + // Parse dates + const profiles = data.profiles as ClaudeProfile[]; + data.profiles = profiles.map((p: ClaudeProfile) => ({ + ...p, + createdAt: new Date(p.createdAt), + lastUsedAt: p.lastUsedAt ? new Date(p.lastUsedAt) : undefined, + usage: p.usage ? { + ...p.usage, + lastUpdated: new Date(p.usage.lastUpdated) + } : undefined, + rateLimitEvents: p.rateLimitEvents?.map(e => ({ + ...e, + hitAt: new Date(e.hitAt), + resetAt: new Date(e.resetAt) + })) + })); + return data as unknown as ProfileStoreData; + } + + return null; +} + /** * Load profiles from disk */ @@ -38,32 +75,7 @@ export function loadProfileStore(storePath: string): ProfileStoreData | null { if (existsSync(storePath)) { const content = readFileSync(storePath, 'utf-8'); const data = JSON.parse(content); - - // Handle version migration - if (data.version === 1) { - // Migrate v1 to v2: add usage and rateLimitEvents fields - data.version = STORE_VERSION; - data.autoSwitch = DEFAULT_AUTO_SWITCH_SETTINGS; - } - - if (data.version === STORE_VERSION) { - // Parse dates - data.profiles = data.profiles.map((p: ClaudeProfile) => ({ - ...p, - createdAt: new Date(p.createdAt), - lastUsedAt: p.lastUsedAt ? new Date(p.lastUsedAt) : undefined, - usage: p.usage ? { - ...p.usage, - lastUpdated: new Date(p.usage.lastUpdated) - } : undefined, - rateLimitEvents: p.rateLimitEvents?.map(e => ({ - ...e, - hitAt: new Date(e.hitAt), - resetAt: new Date(e.resetAt) - })) - })); - return data; - } + return parseAndMigrateProfileData(data); } } catch (error) { console.error('[ProfileStorage] Error loading profiles:', error); @@ -72,6 +84,27 @@ export function loadProfileStore(storePath: string): ProfileStoreData | null { return null; } +/** + * Load profiles from disk (async, non-blocking) + * Use this version for initialization to avoid blocking the main process. + */ +export async function loadProfileStoreAsync(storePath: string): Promise { + try { + // Read file directly - avoid TOCTOU race condition by not checking existence first + // If file doesn't exist, readFile will throw ENOENT which we handle below + const content = await readFile(storePath, 'utf-8'); + const data = JSON.parse(content); + return parseAndMigrateProfileData(data); + } catch (error) { + // ENOENT is expected if file doesn't exist yet + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + console.error('[ProfileStorage] Error loading profiles:', error); + } + } + + return null; +} + /** * Save profiles to disk */ diff --git a/apps/frontend/src/main/cli-tool-manager.ts b/apps/frontend/src/main/cli-tool-manager.ts index 6e6403c5..3f51a1a3 100644 --- a/apps/frontend/src/main/cli-tool-manager.ts +++ b/apps/frontend/src/main/cli-tool-manager.ts @@ -20,18 +20,40 @@ * - Graceful fallbacks when tools not found */ -import { execFileSync } from 'child_process'; -import { existsSync, readdirSync } from 'fs'; +import { execFileSync, execFile } from 'child_process'; +import { existsSync, readdirSync, promises as fsPromises } from 'fs'; import path from 'path'; import os from 'os'; +import { promisify } from 'util'; import { app } from 'electron'; -import { findExecutable, getAugmentedEnv } from './env-utils'; +import { findExecutable, findExecutableAsync, getAugmentedEnv, getAugmentedEnvAsync } from './env-utils'; + +const execFileAsync = promisify(execFile); + +/** + * Check if a path exists asynchronously (non-blocking) + * + * Uses fs.promises.access which is non-blocking, unlike fs.existsSync. + * + * @param filePath - The path to check + * @returns Promise resolving to true if path exists, false otherwise + */ +async function existsAsync(filePath: string): Promise { + try { + await fsPromises.access(filePath); + return true; + } catch { + return false; + } +} import type { ToolDetectionResult } from '../shared/types'; import { findHomebrewPython as findHomebrewPythonUtil } from './utils/homebrew-python'; import { getWindowsExecutablePaths, + getWindowsExecutablePathsAsync, WINDOWS_GIT_PATHS, findWindowsExecutableViaWhere, + findWindowsExecutableViaWhereAsync, } from './utils/windows-paths'; /** @@ -108,6 +130,139 @@ function isWrongPlatformPath(pathStr: string | undefined): boolean { return false; } +// ============================================================================ +// SHARED HELPERS - Used by both sync and async Claude detection +// ============================================================================ + +/** + * Configuration for Claude CLI detection paths + */ +interface ClaudeDetectionPaths { + /** Homebrew paths for macOS (Apple Silicon and Intel) */ + homebrewPaths: string[]; + /** Platform-specific standard installation paths */ + platformPaths: string[]; + /** Path to NVM versions directory for Node.js-installed Claude */ + nvmVersionsDir: string; +} + +/** + * Get all candidate paths for Claude CLI detection. + * + * Returns platform-specific paths where Claude CLI might be installed. + * This pure function consolidates path configuration used by both sync + * and async detection methods. + * + * @param homeDir - User's home directory (from os.homedir()) + * @returns Object containing homebrew, platform, and NVM paths + * + * @example + * const paths = getClaudeDetectionPaths('/Users/john'); + * // On macOS: { homebrewPaths: ['/opt/homebrew/bin/claude', ...], ... } + */ +export function getClaudeDetectionPaths(homeDir: string): ClaudeDetectionPaths { + const homebrewPaths = [ + '/opt/homebrew/bin/claude', // Apple Silicon + '/usr/local/bin/claude', // Intel Mac + ]; + + const platformPaths = process.platform === 'win32' + ? [ + path.join(homeDir, 'AppData', 'Local', 'Programs', 'claude', 'claude.exe'), + path.join(homeDir, 'AppData', 'Roaming', 'npm', 'claude.cmd'), + path.join(homeDir, '.local', 'bin', 'claude.exe'), + 'C:\\Program Files\\Claude\\claude.exe', + 'C:\\Program Files (x86)\\Claude\\claude.exe', + ] + : [ + path.join(homeDir, '.local', 'bin', 'claude'), + path.join(homeDir, 'bin', 'claude'), + ]; + + const nvmVersionsDir = path.join(homeDir, '.nvm', 'versions', 'node'); + + return { homebrewPaths, platformPaths, nvmVersionsDir }; +} + +/** + * Sort NVM version directories by semantic version (newest first). + * + * Filters entries to only include directories starting with 'v' (version directories) + * and sorts them in descending order so the newest Node.js version is checked first. + * + * @param entries - Directory entries from readdir with { name, isDirectory() } + * @returns Array of version directory names sorted newest first + * + * @example + * const entries = [ + * { name: 'v18.0.0', isDirectory: () => true }, + * { name: 'v20.0.0', isDirectory: () => true }, + * { name: '.DS_Store', isDirectory: () => false }, + * ]; + * sortNvmVersionDirs(entries); // ['v20.0.0', 'v18.0.0'] + */ +export function sortNvmVersionDirs( + entries: Array<{ name: string; isDirectory(): boolean }> +): string[] { + // Regex to match valid semver directories: v20.0.0, v18.17.1, etc. + // This prevents NaN from malformed versions (e.g., v20.abc.1) breaking sort + const semverRegex = /^v\d+\.\d+\.\d+$/; + + return entries + .filter((entry) => entry.isDirectory() && semverRegex.test(entry.name)) + .sort((a, b) => { + // Parse version numbers: v20.0.0 -> [20, 0, 0] + const vA = a.name.slice(1).split('.').map(Number); + const vB = b.name.slice(1).split('.').map(Number); + // Compare major, minor, patch in order (descending) + for (let i = 0; i < 3; i++) { + const diff = (vB[i] ?? 0) - (vA[i] ?? 0); + if (diff !== 0) return diff; + } + return 0; + }) + .map((entry) => entry.name); +} + +/** + * Build a ToolDetectionResult from a validation result. + * + * Returns null if validation failed, otherwise constructs the full result object. + * This helper consolidates the result-building logic used throughout detection. + * + * @param claudePath - The path that was validated + * @param validation - The validation result from validateClaude/validateClaudeAsync + * @param source - The source of detection ('user-config', 'homebrew', 'system-path', 'nvm') + * @param messagePrefix - Prefix for the success message (e.g., 'Using Homebrew Claude CLI') + * @returns ToolDetectionResult if valid, null if validation failed + * + * @example + * const result = buildClaudeDetectionResult( + * '/opt/homebrew/bin/claude', + * { valid: true, version: '1.0.0', message: 'OK' }, + * 'homebrew', + * 'Using Homebrew Claude CLI' + * ); + * // Returns: { found: true, path: '/opt/homebrew/bin/claude', version: '1.0.0', ... } + */ +export function buildClaudeDetectionResult( + claudePath: string, + validation: ToolValidation, + source: ToolDetectionResult['source'], + messagePrefix: string +): ToolDetectionResult | null { + if (!validation.valid) { + return null; + } + return { + found: true, + path: claudePath, + version: validation.version, + source, + message: `${messagePrefix}: ${claudePath}`, + }; +} + /** * Centralized CLI Tool Manager * @@ -555,143 +710,75 @@ class CLIToolManager { * @returns Detection result for Claude CLI */ private detectClaude(): ToolDetectionResult { + const homeDir = os.homedir(); + const paths = getClaudeDetectionPaths(homeDir); + // 1. User configuration if (this.userConfig.claudePath) { - // Check if path is from wrong platform (e.g., Windows path on macOS) if (isWrongPlatformPath(this.userConfig.claudePath)) { console.warn( `[Claude CLI] User-configured path is from different platform, ignoring: ${this.userConfig.claudePath}` ); } else { const validation = this.validateClaude(this.userConfig.claudePath); - if (validation.valid) { - return { - found: true, - path: this.userConfig.claudePath, - version: validation.version, - source: 'user-config', - message: `Using user-configured Claude CLI: ${this.userConfig.claudePath}`, - }; - } - console.warn( - `[Claude CLI] User-configured path invalid: ${validation.message}` + const result = buildClaudeDetectionResult( + this.userConfig.claudePath, validation, 'user-config', 'Using user-configured Claude CLI' ); + if (result) return result; + console.warn(`[Claude CLI] User-configured path invalid: ${validation.message}`); } } // 2. Homebrew (macOS) if (process.platform === 'darwin') { - const homebrewPaths = [ - '/opt/homebrew/bin/claude', // Apple Silicon - '/usr/local/bin/claude', // Intel Mac - ]; - - for (const claudePath of homebrewPaths) { + for (const claudePath of paths.homebrewPaths) { if (existsSync(claudePath)) { const validation = this.validateClaude(claudePath); - if (validation.valid) { - return { - found: true, - path: claudePath, - version: validation.version, - source: 'homebrew', - message: `Using Homebrew Claude CLI: ${claudePath}`, - }; - } + const result = buildClaudeDetectionResult(claudePath, validation, 'homebrew', 'Using Homebrew Claude CLI'); + if (result) return result; } } } // 3. System PATH (augmented) - const claudePath = findExecutable('claude'); - if (claudePath) { - const validation = this.validateClaude(claudePath); - if (validation.valid) { - return { - found: true, - path: claudePath, - version: validation.version, - source: 'system-path', - message: `Using system Claude CLI: ${claudePath}`, - }; - } + const systemClaudePath = findExecutable('claude'); + if (systemClaudePath) { + const validation = this.validateClaude(systemClaudePath); + const result = buildClaudeDetectionResult(systemClaudePath, validation, 'system-path', 'Using system Claude CLI'); + if (result) return result; } - // 4. Platform-specific standard locations - const homeDir = os.homedir(); - const platformPaths = process.platform === 'win32' - ? [ - path.join(homeDir, 'AppData', 'Local', 'Programs', 'claude', 'claude.exe'), - path.join(homeDir, 'AppData', 'Roaming', 'npm', 'claude.cmd'), - path.join(homeDir, '.local', 'bin', 'claude.exe'), - 'C:\\Program Files\\Claude\\claude.exe', - 'C:\\Program Files (x86)\\Claude\\claude.exe', - ] - : [ - path.join(homeDir, '.local', 'bin', 'claude'), - path.join(homeDir, 'bin', 'claude'), - ]; - - // 4.5. NVM (Node Version Manager) paths for Unix/Linux/macOS - // NVM installs global npm packages in ~/.nvm/versions/node/vX.X.X/bin/ - // This is important when the app launches from GUI without NVM sourced + // 4. NVM paths (Unix only) - check before platform paths for better Node.js integration if (process.platform !== 'win32') { - const nvmVersionsDir = path.join(homeDir, '.nvm', 'versions', 'node'); try { - if (existsSync(nvmVersionsDir)) { - const nodeVersions = readdirSync(nvmVersionsDir, { withFileTypes: true }); - const versionDirs = nodeVersions - .filter((entry) => entry.isDirectory() && entry.name.startsWith('v')) - .sort((a, b) => { - const vA = a.name.slice(1).split('.').map(Number); - const vB = b.name.slice(1).split('.').map(Number); - for (let i = 0; i < 3; i++) { - const diff = (vB[i] ?? 0) - (vA[i] ?? 0); - if (diff !== 0) { - return diff; - } - } - return 0; - }); + if (existsSync(paths.nvmVersionsDir)) { + const nodeVersions = readdirSync(paths.nvmVersionsDir, { withFileTypes: true }); + const versionNames = sortNvmVersionDirs(nodeVersions); - for (const entry of versionDirs) { - const nvmClaudePath = path.join(nvmVersionsDir, entry.name, 'bin', 'claude'); + for (const versionName of versionNames) { + const nvmClaudePath = path.join(paths.nvmVersionsDir, versionName, 'bin', 'claude'); if (existsSync(nvmClaudePath)) { const validation = this.validateClaude(nvmClaudePath); - if (validation.valid) { - return { - found: true, - path: nvmClaudePath, - version: validation.version, - source: 'nvm', - message: `Using NVM Claude CLI: ${nvmClaudePath}`, - }; - } + const result = buildClaudeDetectionResult(nvmClaudePath, validation, 'nvm', 'Using NVM Claude CLI'); + if (result) return result; } } } } catch (error) { - // Silently fail if unable to read NVM directory console.warn(`[Claude CLI] Unable to read NVM directory: ${error}`); } } - for (const claudePath of platformPaths) { + // 5. Platform-specific standard locations + for (const claudePath of paths.platformPaths) { if (existsSync(claudePath)) { const validation = this.validateClaude(claudePath); - if (validation.valid) { - return { - found: true, - path: claudePath, - version: validation.version, - source: 'system-path', - message: `Using Claude CLI: ${claudePath}`, - }; - } + const result = buildClaudeDetectionResult(claudePath, validation, 'system-path', 'Using Claude CLI'); + if (result) return result; } } - // 5. Not found + // 6. Not found return { found: false, source: 'fallback', @@ -861,6 +948,637 @@ class CLIToolManager { } } + // ============================================================================ + // ASYNC METHODS - Non-blocking alternatives for Electron main process + // ============================================================================ + + /** + * Get the path for a CLI tool asynchronously (non-blocking) + * + * Uses cached path if available, otherwise detects asynchronously. + * Safe to call from Electron main process without blocking. + * + * @param tool - The CLI tool to get the path for + * @returns Promise resolving to the tool path + */ + async getToolPathAsync(tool: CLITool): Promise { + // Check cache first (instant return if cached) + const cached = this.cache.get(tool); + if (cached) { + console.warn( + `[CLI Tools] Using cached ${tool}: ${cached.path} (${cached.source})` + ); + return cached.path; + } + + // Detect asynchronously + const result = await this.detectToolPathAsync(tool); + if (result.found && result.path) { + this.cache.set(tool, { + path: result.path, + version: result.version, + source: result.source, + }); + console.warn(`[CLI Tools] Detected ${tool}: ${result.path} (${result.source})`); + return result.path; + } + + // Fallback to tool name (let system PATH resolve it) + console.warn(`[CLI Tools] ${tool} not found, using fallback: "${tool}"`); + return tool; + } + + /** + * Detect tool path asynchronously + * + * All tools now use async detection methods to prevent blocking the main process. + * + * @param tool - The tool to detect + * @returns Promise resolving to detection result + */ + private async detectToolPathAsync(tool: CLITool): Promise { + switch (tool) { + case 'claude': + return this.detectClaudeAsync(); + case 'python': + return this.detectPythonAsync(); + case 'git': + return this.detectGitAsync(); + case 'gh': + return this.detectGitHubCLIAsync(); + default: + return { + found: false, + source: 'fallback', + message: `Unknown tool: ${tool}`, + }; + } + } + + /** + * Validate Claude CLI asynchronously (non-blocking) + * + * @param claudeCmd - The Claude CLI command to validate + * @returns Promise resolving to validation result + */ + private async validateClaudeAsync(claudeCmd: string): Promise { + try { + const needsShell = process.platform === 'win32' && + (claudeCmd.endsWith('.cmd') || claudeCmd.endsWith('.bat')); + + const { stdout } = await execFileAsync(claudeCmd, ['--version'], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + shell: needsShell, + env: await getAugmentedEnvAsync(), + }); + + const version = stdout.trim(); + const match = version.match(/(\d+\.\d+\.\d+)/); + const versionStr = match ? match[1] : version.split('\n')[0]; + + return { + valid: true, + version: versionStr, + message: `Claude CLI ${versionStr} is available`, + }; + } catch (error) { + return { + valid: false, + message: `Failed to validate Claude CLI: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + /** + * Validate Python version asynchronously (non-blocking) + * + * @param pythonCmd - The Python command to validate + * @returns Promise resolving to validation result + */ + private async validatePythonAsync(pythonCmd: string): Promise { + const MINIMUM_VERSION = '3.10.0'; + + try { + const parts = pythonCmd.split(' '); + const cmd = parts[0]; + const args = [...parts.slice(1), '--version']; + + const { stdout } = await execFileAsync(cmd, args, { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + env: await getAugmentedEnvAsync(), + }); + + const version = stdout.trim(); + const match = version.match(/Python (\d+\.\d+\.\d+)/); + if (!match) { + return { + valid: false, + message: 'Unable to detect Python version', + }; + } + + const versionStr = match[1]; + const [major, minor] = versionStr.split('.').map(Number); + const [reqMajor, reqMinor] = MINIMUM_VERSION.split('.').map(Number); + + const meetsRequirement = + major > reqMajor || (major === reqMajor && minor >= reqMinor); + + if (!meetsRequirement) { + return { + valid: false, + version: versionStr, + message: `Python ${versionStr} is too old. Requires ${MINIMUM_VERSION}+`, + }; + } + + return { + valid: true, + version: versionStr, + message: `Python ${versionStr} meets requirements`, + }; + } catch (error) { + return { + valid: false, + message: `Failed to validate Python: ${error}`, + }; + } + } + + /** + * Validate Git asynchronously (non-blocking) + * + * @param gitCmd - The Git command to validate + * @returns Promise resolving to validation result + */ + private async validateGitAsync(gitCmd: string): Promise { + try { + const { stdout } = await execFileAsync(gitCmd, ['--version'], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + env: await getAugmentedEnvAsync(), + }); + + const version = stdout.trim(); + const match = version.match(/git version (\d+\.\d+\.\d+)/); + const versionStr = match ? match[1] : version; + + return { + valid: true, + version: versionStr, + message: `Git ${versionStr} is available`, + }; + } catch (error) { + return { + valid: false, + message: `Failed to validate Git: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + /** + * Validate GitHub CLI asynchronously (non-blocking) + * + * @param ghCmd - The GitHub CLI command to validate + * @returns Promise resolving to validation result + */ + private async validateGitHubCLIAsync(ghCmd: string): Promise { + try { + const { stdout } = await execFileAsync(ghCmd, ['--version'], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + env: await getAugmentedEnvAsync(), + }); + + const version = stdout.trim(); + const match = version.match(/gh version (\d+\.\d+\.\d+)/); + const versionStr = match ? match[1] : version.split('\n')[0]; + + return { + valid: true, + version: versionStr, + message: `GitHub CLI ${versionStr} is available`, + }; + } catch (error) { + return { + valid: false, + message: `Failed to validate GitHub CLI: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } + + /** + * Detect Claude CLI asynchronously (non-blocking) + * + * Same detection logic as detectClaude but uses async validation. + * + * @returns Promise resolving to detection result + */ + private async detectClaudeAsync(): Promise { + const homeDir = os.homedir(); + const paths = getClaudeDetectionPaths(homeDir); + + // 1. User configuration + if (this.userConfig.claudePath) { + if (isWrongPlatformPath(this.userConfig.claudePath)) { + console.warn( + `[Claude CLI] User-configured path is from different platform, ignoring: ${this.userConfig.claudePath}` + ); + } else { + const validation = await this.validateClaudeAsync(this.userConfig.claudePath); + const result = buildClaudeDetectionResult( + this.userConfig.claudePath, validation, 'user-config', 'Using user-configured Claude CLI' + ); + if (result) return result; + console.warn(`[Claude CLI] User-configured path invalid: ${validation.message}`); + } + } + + // 2. Homebrew (macOS) + if (process.platform === 'darwin') { + for (const claudePath of paths.homebrewPaths) { + if (await existsAsync(claudePath)) { + const validation = await this.validateClaudeAsync(claudePath); + const result = buildClaudeDetectionResult(claudePath, validation, 'homebrew', 'Using Homebrew Claude CLI'); + if (result) return result; + } + } + } + + // 3. System PATH (augmented) - using async findExecutable + const systemClaudePath = await findExecutableAsync('claude'); + if (systemClaudePath) { + const validation = await this.validateClaudeAsync(systemClaudePath); + const result = buildClaudeDetectionResult(systemClaudePath, validation, 'system-path', 'Using system Claude CLI'); + if (result) return result; + } + + // 4. NVM paths (Unix only) - check before platform paths for better Node.js integration + if (process.platform !== 'win32') { + try { + if (await existsAsync(paths.nvmVersionsDir)) { + const nodeVersions = await fsPromises.readdir(paths.nvmVersionsDir, { withFileTypes: true }); + const versionNames = sortNvmVersionDirs(nodeVersions); + + for (const versionName of versionNames) { + const nvmClaudePath = path.join(paths.nvmVersionsDir, versionName, 'bin', 'claude'); + if (await existsAsync(nvmClaudePath)) { + const validation = await this.validateClaudeAsync(nvmClaudePath); + const result = buildClaudeDetectionResult(nvmClaudePath, validation, 'nvm', 'Using NVM Claude CLI'); + if (result) return result; + } + } + } + } catch (error) { + console.warn(`[Claude CLI] Unable to read NVM directory: ${error}`); + } + } + + // 5. Platform-specific standard locations + for (const claudePath of paths.platformPaths) { + if (await existsAsync(claudePath)) { + const validation = await this.validateClaudeAsync(claudePath); + const result = buildClaudeDetectionResult(claudePath, validation, 'system-path', 'Using Claude CLI'); + if (result) return result; + } + } + + // 6. Not found + return { + found: false, + source: 'fallback', + message: 'Claude CLI not found. Install from https://claude.ai/download', + }; + } + + /** + * Detect Python asynchronously (non-blocking) + * + * Same detection logic as detectPython but uses async validation. + * + * @returns Promise resolving to detection result + */ + private async detectPythonAsync(): Promise { + const MINIMUM_VERSION = '3.10.0'; + + // 1. User configuration + if (this.userConfig.pythonPath) { + if (isWrongPlatformPath(this.userConfig.pythonPath)) { + console.warn( + `[Python] User-configured path is from different platform, ignoring: ${this.userConfig.pythonPath}` + ); + } else { + const validation = await this.validatePythonAsync(this.userConfig.pythonPath); + if (validation.valid) { + return { + found: true, + path: this.userConfig.pythonPath, + version: validation.version, + source: 'user-config', + message: `Using user-configured Python: ${this.userConfig.pythonPath}`, + }; + } + console.warn(`[Python] User-configured path invalid: ${validation.message}`); + } + } + + // 2. Bundled Python (packaged apps only) + if (app.isPackaged) { + const bundledPath = this.getBundledPythonPath(); + if (bundledPath) { + const validation = await this.validatePythonAsync(bundledPath); + if (validation.valid) { + return { + found: true, + path: bundledPath, + version: validation.version, + source: 'bundled', + message: `Using bundled Python: ${bundledPath}`, + }; + } + } + } + + // 3. Homebrew Python (macOS) - simplified async version + if (process.platform === 'darwin') { + const homebrewPaths = [ + '/opt/homebrew/bin/python3', + '/opt/homebrew/bin/python3.12', + '/opt/homebrew/bin/python3.11', + '/opt/homebrew/bin/python3.10', + '/usr/local/bin/python3', + ]; + for (const pythonPath of homebrewPaths) { + if (await existsAsync(pythonPath)) { + const validation = await this.validatePythonAsync(pythonPath); + if (validation.valid) { + return { + found: true, + path: pythonPath, + version: validation.version, + source: 'homebrew', + message: `Using Homebrew Python: ${pythonPath}`, + }; + } + } + } + } + + // 4. System PATH (augmented) + const candidates = + process.platform === 'win32' + ? ['py -3', 'python', 'python3', 'py'] + : ['python3', 'python']; + + for (const cmd of candidates) { + if (cmd.startsWith('py ')) { + const validation = await this.validatePythonAsync(cmd); + if (validation.valid) { + return { + found: true, + path: cmd, + version: validation.version, + source: 'system-path', + message: `Using system Python: ${cmd}`, + }; + } + } else { + const pythonPath = await findExecutableAsync(cmd); + if (pythonPath) { + const validation = await this.validatePythonAsync(pythonPath); + if (validation.valid) { + return { + found: true, + path: pythonPath, + version: validation.version, + source: 'system-path', + message: `Using system Python: ${pythonPath}`, + }; + } + } + } + } + + // 5. Not found + return { + found: false, + source: 'fallback', + message: + `Python ${MINIMUM_VERSION}+ not found. ` + + 'Please install Python or configure in Settings.', + }; + } + + /** + * Detect Git asynchronously (non-blocking) + * + * Same detection logic as detectGit but uses async validation. + * + * @returns Promise resolving to detection result + */ + private async detectGitAsync(): Promise { + // 1. User configuration + if (this.userConfig.gitPath) { + if (isWrongPlatformPath(this.userConfig.gitPath)) { + console.warn( + `[Git] User-configured path is from different platform, ignoring: ${this.userConfig.gitPath}` + ); + } else { + const validation = await this.validateGitAsync(this.userConfig.gitPath); + if (validation.valid) { + return { + found: true, + path: this.userConfig.gitPath, + version: validation.version, + source: 'user-config', + message: `Using user-configured Git: ${this.userConfig.gitPath}`, + }; + } + console.warn(`[Git] User-configured path invalid: ${validation.message}`); + } + } + + // 2. Homebrew (macOS) + if (process.platform === 'darwin') { + const homebrewPaths = [ + '/opt/homebrew/bin/git', + '/usr/local/bin/git', + ]; + + for (const gitPath of homebrewPaths) { + if (await existsAsync(gitPath)) { + const validation = await this.validateGitAsync(gitPath); + if (validation.valid) { + return { + found: true, + path: gitPath, + version: validation.version, + source: 'homebrew', + message: `Using Homebrew Git: ${gitPath}`, + }; + } + } + } + } + + // 3. System PATH (augmented) + const gitPath = await findExecutableAsync('git'); + if (gitPath) { + const validation = await this.validateGitAsync(gitPath); + if (validation.valid) { + return { + found: true, + path: gitPath, + version: validation.version, + source: 'system-path', + message: `Using system Git: ${gitPath}`, + }; + } + } + + // 4. Windows-specific detection (async to avoid blocking main process) + if (process.platform === 'win32') { + const whereGitPath = await findWindowsExecutableViaWhereAsync('git', '[Git]'); + if (whereGitPath) { + const validation = await this.validateGitAsync(whereGitPath); + if (validation.valid) { + return { + found: true, + path: whereGitPath, + version: validation.version, + source: 'system-path', + message: `Using Windows Git: ${whereGitPath}`, + }; + } + } + + const windowsPaths = await getWindowsExecutablePathsAsync(WINDOWS_GIT_PATHS, '[Git]'); + for (const winGitPath of windowsPaths) { + const validation = await this.validateGitAsync(winGitPath); + if (validation.valid) { + return { + found: true, + path: winGitPath, + version: validation.version, + source: 'system-path', + message: `Using Windows Git: ${winGitPath}`, + }; + } + } + } + + // 5. Not found + return { + found: false, + source: 'fallback', + message: 'Git not found in standard locations. Using fallback "git".', + }; + } + + /** + * Detect GitHub CLI asynchronously (non-blocking) + * + * Same detection logic as detectGitHubCLI but uses async validation. + * + * @returns Promise resolving to detection result + */ + private async detectGitHubCLIAsync(): Promise { + // 1. User configuration + if (this.userConfig.githubCLIPath) { + if (isWrongPlatformPath(this.userConfig.githubCLIPath)) { + console.warn( + `[GitHub CLI] User-configured path is from different platform, ignoring: ${this.userConfig.githubCLIPath}` + ); + } else { + const validation = await this.validateGitHubCLIAsync(this.userConfig.githubCLIPath); + if (validation.valid) { + return { + found: true, + path: this.userConfig.githubCLIPath, + version: validation.version, + source: 'user-config', + message: `Using user-configured GitHub CLI: ${this.userConfig.githubCLIPath}`, + }; + } + console.warn(`[GitHub CLI] User-configured path invalid: ${validation.message}`); + } + } + + // 2. Homebrew (macOS) + if (process.platform === 'darwin') { + const homebrewPaths = [ + '/opt/homebrew/bin/gh', + '/usr/local/bin/gh', + ]; + + for (const ghPath of homebrewPaths) { + if (await existsAsync(ghPath)) { + const validation = await this.validateGitHubCLIAsync(ghPath); + if (validation.valid) { + return { + found: true, + path: ghPath, + version: validation.version, + source: 'homebrew', + message: `Using Homebrew GitHub CLI: ${ghPath}`, + }; + } + } + } + } + + // 3. System PATH (augmented) + const ghPath = await findExecutableAsync('gh'); + if (ghPath) { + const validation = await this.validateGitHubCLIAsync(ghPath); + if (validation.valid) { + return { + found: true, + path: ghPath, + version: validation.version, + source: 'system-path', + message: `Using system GitHub CLI: ${ghPath}`, + }; + } + } + + // 4. Windows Program Files + if (process.platform === 'win32') { + const windowsPaths = [ + 'C:\\Program Files\\GitHub CLI\\gh.exe', + 'C:\\Program Files (x86)\\GitHub CLI\\gh.exe', + ]; + + for (const winGhPath of windowsPaths) { + if (await existsAsync(winGhPath)) { + const validation = await this.validateGitHubCLIAsync(winGhPath); + if (validation.valid) { + return { + found: true, + path: winGhPath, + version: validation.version, + source: 'system-path', + message: `Using Windows GitHub CLI: ${winGhPath}`, + }; + } + } + } + } + + // 5. Not found + return { + found: false, + source: 'fallback', + message: 'GitHub CLI (gh) not found. Install from https://cli.github.com', + }; + } + /** * Get bundled Python path for packaged apps * @@ -1034,3 +1752,52 @@ export function clearToolCache(): void { export function isPathFromWrongPlatform(pathStr: string | undefined): boolean { return isWrongPlatformPath(pathStr); } + +// ============================================================================ +// ASYNC EXPORTS - Non-blocking alternatives for Electron main process +// ============================================================================ + +/** + * Get the path for a CLI tool asynchronously (non-blocking) + * + * Safe to call from Electron main process without blocking the event loop. + * Uses cached path if available, otherwise detects asynchronously. + * + * @param tool - The CLI tool to get the path for + * @returns Promise resolving to the tool path + * + * @example + * ```typescript + * import { getToolPathAsync } from './cli-tool-manager'; + * + * const claudePath = await getToolPathAsync('claude'); + * ``` + */ +export async function getToolPathAsync(tool: CLITool): Promise { + return cliToolManager.getToolPathAsync(tool); +} + +/** + * Pre-warm the CLI tool cache asynchronously + * + * Call this during app startup to detect tools in the background. + * Subsequent calls to getToolPath/getToolPathAsync will use cached values. + * + * @param tools - Array of tools to pre-warm (defaults to ['claude']) + * + * @example + * ```typescript + * import { preWarmToolCache } from './cli-tool-manager'; + * + * // In app startup + * app.whenReady().then(() => { + * // ... setup code ... + * preWarmToolCache(['claude', 'git', 'gh']); + * }); + * ``` + */ +export async function preWarmToolCache(tools: CLITool[] = ['claude']): Promise { + console.warn('[CLI Tools] Pre-warming cache for:', tools.join(', ')); + await Promise.all(tools.map(tool => cliToolManager.getToolPathAsync(tool))); + console.warn('[CLI Tools] Cache pre-warming complete'); +} diff --git a/apps/frontend/src/main/env-utils.ts b/apps/frontend/src/main/env-utils.ts index bf13a839..bf863c8f 100644 --- a/apps/frontend/src/main/env-utils.ts +++ b/apps/frontend/src/main/env-utils.ts @@ -12,7 +12,32 @@ import * as os from 'os'; import * as path from 'path'; import * as fs from 'fs'; -import { execFileSync } from 'child_process'; +import { promises as fsPromises } from 'fs'; +import { execFileSync, execFile } from 'child_process'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); + +/** + * Check if a path exists asynchronously (non-blocking) + * + * Uses fs.promises.access which is non-blocking, unlike fs.existsSync. + * + * @param filePath - The path to check + * @returns Promise resolving to true if path exists, false otherwise + */ +async function existsAsync(filePath: string): Promise { + try { + await fsPromises.access(filePath); + return true; + } catch { + return false; + } +} + +// Cache for npm global prefix to avoid repeated async calls +let npmGlobalPrefixCache: string | null | undefined = undefined; +let npmGlobalPrefixCachePromise: Promise | null = null; /** * Get npm global prefix directory dynamically @@ -35,6 +60,7 @@ function getNpmGlobalPrefix(): string | null { encoding: 'utf-8', timeout: 3000, windowsHide: true, + cwd: os.homedir(), // Run from home dir to avoid ENOWORKSPACES error in monorepos shell: process.platform === 'win32', // Enable shell on Windows for .cmd resolution }).trim(); @@ -61,7 +87,7 @@ function getNpmGlobalPrefix(): string | null { * Common binary directories that should be in PATH * These are locations where commonly used tools are installed */ -const COMMON_BIN_PATHS: Record = { +export const COMMON_BIN_PATHS: Record = { darwin: [ '/opt/homebrew/bin', // Apple Silicon Homebrew '/usr/local/bin', // Intel Homebrew / system @@ -86,6 +112,71 @@ const COMMON_BIN_PATHS: Record = { ], }; +/** + * Get expanded platform paths for PATH augmentation + * + * Shared helper used by both sync and async getAugmentedEnv functions. + * Expands home directory (~) in paths and returns the list of candidate paths. + * + * @param additionalPaths - Optional additional paths to include + * @returns Array of expanded paths (without existence checking) + */ +function getExpandedPlatformPaths(additionalPaths?: string[]): string[] { + const platform = process.platform as 'darwin' | 'linux' | 'win32'; + const homeDir = os.homedir(); + + // Get platform-specific paths and expand home directory + const platformPaths = COMMON_BIN_PATHS[platform] || []; + const expandedPaths = platformPaths.map(p => + p.startsWith('~') ? p.replace('~', homeDir) : p + ); + + // Add user-requested additional paths (expanded) + if (additionalPaths) { + for (const p of additionalPaths) { + const expanded = p.startsWith('~') ? p.replace('~', homeDir) : p; + expandedPaths.push(expanded); + } + } + + return expandedPaths; +} + +/** + * Build augmented PATH by filtering existing paths + * + * Shared helper that takes candidate paths and a set of current PATH entries, + * returning only paths that should be added. + * + * @param candidatePaths - Array of paths to consider adding + * @param currentPathSet - Set of paths already in PATH + * @param existingPaths - Array of paths that actually exist on the filesystem + * @param npmPrefix - npm global prefix path (or null if not found) + * @returns Array of paths to prepend to PATH + */ +function buildPathsToAdd( + candidatePaths: string[], + currentPathSet: Set, + existingPaths: Set, + npmPrefix: string | null +): string[] { + const pathsToAdd: string[] = []; + + // Add platform-specific paths that exist + for (const p of candidatePaths) { + if (!currentPathSet.has(p) && existingPaths.has(p)) { + pathsToAdd.push(p); + } + } + + // Add npm global prefix if it exists + if (npmPrefix && !currentPathSet.has(npmPrefix) && existingPaths.has(npmPrefix)) { + pathsToAdd.push(npmPrefix); + } + + return pathsToAdd; +} + /** * Get augmented environment with additional PATH entries * @@ -101,43 +192,24 @@ export function getAugmentedEnv(additionalPaths?: string[]): Record - p.startsWith('~') ? p.replace('~', homeDir) : p - ); + // Get all candidate paths (platform + additional) + const candidatePaths = getExpandedPlatformPaths(additionalPaths); // Collect paths to add (only if they exist and aren't already in PATH) const currentPath = env.PATH || ''; const currentPathSet = new Set(currentPath.split(pathSeparator)); - const pathsToAdd: string[] = []; + // Check existence synchronously and build existing paths set + const existingPaths = new Set(candidatePaths.filter(p => fs.existsSync(p))); - // Add platform-specific paths - for (const p of expandedPaths) { - if (!currentPathSet.has(p) && fs.existsSync(p)) { - pathsToAdd.push(p); - } - } - - // Add npm global prefix dynamically (cross-platform: works with standard npm, nvm, nvm-windows) + // Get npm global prefix dynamically const npmPrefix = getNpmGlobalPrefix(); - if (npmPrefix && !currentPathSet.has(npmPrefix) && fs.existsSync(npmPrefix)) { - pathsToAdd.push(npmPrefix); + if (npmPrefix && fs.existsSync(npmPrefix)) { + existingPaths.add(npmPrefix); } - // Add user-requested additional paths - if (additionalPaths) { - for (const p of additionalPaths) { - const expanded = p.startsWith('~') ? p.replace('~', homeDir) : p; - if (!currentPathSet.has(expanded) && fs.existsSync(expanded)) { - pathsToAdd.push(expanded); - } - } - } + // Build final paths to add using shared helper + const pathsToAdd = buildPathsToAdd(candidatePaths, currentPathSet, existingPaths, npmPrefix); // Prepend new paths to PATH (prepend so they take priority) if (pathsToAdd.length > 0) { @@ -188,3 +260,149 @@ export function findExecutable(command: string): string | null { export function isCommandAvailable(command: string): boolean { return findExecutable(command) !== null; } + +// ============================================================================ +// ASYNC VERSIONS - Non-blocking alternatives for Electron main process +// ============================================================================ + +/** + * Get npm global prefix directory asynchronously (non-blocking) + * + * Uses caching to avoid repeated subprocess calls. Safe to call from + * Electron main process without blocking the event loop. + * + * @returns Promise resolving to npm global binaries directory, or null + */ +async function getNpmGlobalPrefixAsync(): Promise { + // Return cached value if available + if (npmGlobalPrefixCache !== undefined) { + return npmGlobalPrefixCache; + } + + // If a fetch is already in progress, wait for it + if (npmGlobalPrefixCachePromise) { + return npmGlobalPrefixCachePromise; + } + + // Start the async fetch + npmGlobalPrefixCachePromise = (async () => { + try { + const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; + + const { stdout } = await execFileAsync(npmCommand, ['config', 'get', 'prefix', '--location=global'], { + encoding: 'utf-8', + timeout: 3000, + windowsHide: true, + cwd: os.homedir(), // Run from home dir to avoid ENOWORKSPACES error in monorepos + shell: process.platform === 'win32', + }); + + const rawPrefix = stdout.trim(); + if (!rawPrefix) { + npmGlobalPrefixCache = null; + return null; + } + + const binPath = process.platform === 'win32' + ? rawPrefix + : path.join(rawPrefix, 'bin'); + + const normalizedPath = path.normalize(binPath); + npmGlobalPrefixCache = await existsAsync(normalizedPath) ? normalizedPath : null; + return npmGlobalPrefixCache; + } catch (error) { + console.warn(`[env-utils] Failed to get npm global prefix: ${error}`); + npmGlobalPrefixCache = null; + return null; + } finally { + npmGlobalPrefixCachePromise = null; + } + })(); + + return npmGlobalPrefixCachePromise; +} + +/** + * Get augmented environment asynchronously (non-blocking) + * + * Same as getAugmentedEnv but uses async npm prefix detection. + * Safe to call from Electron main process without blocking. + * + * @param additionalPaths - Optional array of additional paths to include + * @returns Promise resolving to environment object with augmented PATH + */ +export async function getAugmentedEnvAsync(additionalPaths?: string[]): Promise> { + const env = { ...process.env } as Record; + const platform = process.platform as 'darwin' | 'linux' | 'win32'; + const pathSeparator = platform === 'win32' ? ';' : ':'; + + // Get all candidate paths (platform + additional) + const candidatePaths = getExpandedPlatformPaths(additionalPaths); + + // Collect paths to add (only if they exist and aren't already in PATH) + const currentPath = env.PATH || ''; + const currentPathSet = new Set(currentPath.split(pathSeparator)); + + // Check existence asynchronously in parallel for performance + const pathChecks = await Promise.all( + candidatePaths.map(async (p) => ({ path: p, exists: await existsAsync(p) })) + ); + const existingPaths = new Set( + pathChecks.filter(({ exists }) => exists).map(({ path: p }) => p) + ); + + // Get npm global prefix dynamically (async - non-blocking) + const npmPrefix = await getNpmGlobalPrefixAsync(); + if (npmPrefix && await existsAsync(npmPrefix)) { + existingPaths.add(npmPrefix); + } + + // Build final paths to add using shared helper + const pathsToAdd = buildPathsToAdd(candidatePaths, currentPathSet, existingPaths, npmPrefix); + + // Prepend new paths to PATH (prepend so they take priority) + if (pathsToAdd.length > 0) { + env.PATH = [...pathsToAdd, currentPath].filter(Boolean).join(pathSeparator); + } + + return env; +} + +/** + * Find the full path to an executable asynchronously (non-blocking) + * + * Same as findExecutable but uses async environment augmentation. + * + * @param command - The command name to find (e.g., 'gh', 'git') + * @returns Promise resolving to the full path to the executable, or null + */ +export async function findExecutableAsync(command: string): Promise { + const env = await getAugmentedEnvAsync(); + const pathSeparator = process.platform === 'win32' ? ';' : ':'; + const pathDirs = (env.PATH || '').split(pathSeparator); + + const extensions = process.platform === 'win32' + ? ['.exe', '.cmd', '.bat', '.ps1', ''] + : ['']; + + for (const dir of pathDirs) { + for (const ext of extensions) { + const fullPath = path.join(dir, command + ext); + if (await existsAsync(fullPath)) { + return fullPath; + } + } + } + + return null; +} + +/** + * Clear the npm global prefix cache + * + * Call this if npm configuration changes and you need fresh detection. + */ +export function clearNpmPrefixCache(): void { + npmGlobalPrefixCache = undefined; + npmGlobalPrefixCachePromise = null; +} diff --git a/apps/frontend/src/main/index.ts b/apps/frontend/src/main/index.ts index 041ec161..8ee2eaf7 100644 --- a/apps/frontend/src/main/index.ts +++ b/apps/frontend/src/main/index.ts @@ -35,6 +35,8 @@ import { DEFAULT_APP_SETTINGS } from '../shared/constants'; import { readSettingsFile } from './settings-utils'; import { setupErrorLogging } from './app-logger'; import { initSentryMain } from './sentry'; +import { preWarmToolCache } from './cli-tool-manager'; +import { initializeClaudeProfileManager } from './claude-profile-manager'; import type { AppSettings } from '../shared/types'; // ───────────────────────────────────────────────────────────────────────────── @@ -348,6 +350,23 @@ app.whenReady().then(() => { // Create window createWindow(); + // Pre-warm CLI tool cache in background (non-blocking) + // This ensures CLI detection is done before user needs it + // Include all commonly used tools to prevent sync blocking on first use + setImmediate(() => { + preWarmToolCache(['claude', 'git', 'gh', 'python']).catch((error) => { + console.warn('[main] Failed to pre-warm CLI cache:', error); + }); + }); + + // Pre-initialize Claude profile manager in background (non-blocking) + // This ensures profile data is loaded before user clicks "Start Claude Code" + setImmediate(() => { + initializeClaudeProfileManager().catch((error) => { + console.warn('[main] Failed to pre-initialize profile manager:', error); + }); + }); + // Initialize usage monitoring after window is created if (mainWindow) { // Setup event forwarding from usage monitor to renderer diff --git a/apps/frontend/src/main/ipc-handlers/env-handlers.ts b/apps/frontend/src/main/ipc-handlers/env-handlers.ts index b5038003..99ab0790 100644 --- a/apps/frontend/src/main/ipc-handlers/env-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/env-handlers.ts @@ -8,7 +8,7 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import { spawn } from 'child_process'; import { projectStore } from '../project-store'; import { parseEnvFile } from './utils'; -import { getClaudeCliInvocation } from '../claude-cli-utils'; +import { getClaudeCliInvocation, getClaudeCliInvocationAsync } from '../claude-cli-utils'; import { debugError } from '../../shared/utils/debug-logger'; // GitLab environment variable keys @@ -46,6 +46,24 @@ function resolveClaudeCliInvocation(): ResolvedClaudeCliInvocation { } } +/** + * Async version of resolveClaudeCliInvocation - non-blocking for main process + */ +async function resolveClaudeCliInvocationAsync(): Promise { + try { + const invocation = await getClaudeCliInvocationAsync(); + if (!invocation?.command) { + throw new Error('Claude CLI path not resolved'); + } + return { command: invocation.command, env: invocation.env }; + } catch (error) { + debugError('[IPC] Failed to resolve Claude CLI path:', error); + return { + error: error instanceof Error ? error.message : 'Failed to resolve Claude CLI path', + }; + } +} + /** * Register all env-related IPC handlers @@ -573,7 +591,8 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_ return { success: false, error: 'Project not found' }; } - const resolved = resolveClaudeCliInvocation(); + // Use async version to avoid blocking main process during CLI detection + const resolved = await resolveClaudeCliInvocationAsync(); if ('error' in resolved) { return { success: false, error: resolved.error }; } @@ -663,7 +682,8 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_ return { success: false, error: 'Project not found' }; } - const resolved = resolveClaudeCliInvocation(); + // Use async version to avoid blocking main process during CLI detection + const resolved = await resolveClaudeCliInvocationAsync(); if ('error' in resolved) { return { success: false, error: resolved.error }; } diff --git a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts index 493f9c46..9aecfca9 100644 --- a/apps/frontend/src/main/ipc-handlers/settings-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/settings-handlers.ts @@ -14,7 +14,7 @@ import { AgentManager } from '../agent'; import type { BrowserWindow } from 'electron'; import { setUpdateChannel, setUpdateChannelWithDowngradeCheck } from '../app-updater'; import { getSettingsPath, readSettingsFile } from '../settings-utils'; -import { configureTools, getToolPath, getToolInfo, isPathFromWrongPlatform } from '../cli-tool-manager'; +import { configureTools, getToolPath, getToolInfo, isPathFromWrongPlatform, preWarmToolCache } from '../cli-tool-manager'; import { parseEnvFile } from './utils'; const settingsPath = getSettingsPath(); @@ -171,6 +171,11 @@ export function registerSettingsHandlers( claudePath: settings.claudePath, }); + // Re-warm cache asynchronously after configuring (non-blocking) + preWarmToolCache(['claude']).catch((error) => { + console.warn('[SETTINGS_GET] Failed to re-warm CLI cache:', error); + }); + return { success: true, data: settings as AppSettings }; } ); @@ -212,6 +217,11 @@ export function registerSettingsHandlers( githubCLIPath: newSettings.githubCLIPath, claudePath: newSettings.claudePath, }); + + // Re-warm cache asynchronously after configuring (non-blocking) + preWarmToolCache(['claude']).catch((error) => { + console.warn('[SETTINGS_SAVE] Failed to re-warm CLI cache:', error); + }); } // Update auto-updater channel if betaUpdates setting changed diff --git a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts index c5e98984..d68e8ab9 100644 --- a/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/terminal-handlers.ts @@ -9,7 +9,7 @@ import { projectStore } from '../project-store'; import { terminalNameGenerator } from '../terminal-name-generator'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; import { escapeShellArg, escapeShellArgWindows } from '../../shared/utils/shell-escape'; -import { getClaudeCliInvocation } from '../claude-cli-utils'; +import { getClaudeCliInvocationAsync } from '../claude-cli-utils'; /** @@ -54,7 +54,10 @@ export function registerTerminalHandlers( ipcMain.on( IPC_CHANNELS.TERMINAL_INVOKE_CLAUDE, (_, id: string, cwd?: string) => { - terminalManager.invokeClaude(id, cwd); + // Use async version to avoid blocking main process during CLI detection + terminalManager.invokeClaudeAsync(id, cwd).catch((error) => { + console.error('[terminal-handlers] Failed to invoke Claude:', error); + }); } ); @@ -354,7 +357,7 @@ export function registerTerminalHandlers( // Build the login command with the profile's config dir // Use platform-specific syntax and escaping for environment variables let loginCommand: string; - const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation(); + const { command: claudeCmd, env: claudeEnv } = await getClaudeCliInvocationAsync(); const pathPrefix = claudeEnv.PATH ? (process.platform === 'win32' ? `set "PATH=${escapeShellArgWindows(claudeEnv.PATH)}" && ` @@ -635,7 +638,10 @@ export function registerTerminalHandlers( ipcMain.on( IPC_CHANNELS.TERMINAL_RESUME_CLAUDE, (_, id: string, sessionId?: string) => { - terminalManager.resumeClaude(id, sessionId); + // Use async version to avoid blocking main process during CLI detection + terminalManager.resumeClaudeAsync(id, sessionId).catch((error) => { + console.error('[terminal-handlers] Failed to resume Claude:', error); + }); } ); diff --git a/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts index a72f5550..739b58bd 100644 --- a/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts +++ b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts @@ -60,6 +60,14 @@ vi.mock('../session-handler', () => ({ releaseSessionId: mockReleaseSessionId, })); +vi.mock('os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + tmpdir: vi.fn(() => '/tmp'), + }; +}); + describe('claude-integration-handler', () => { beforeEach(() => { mockGetClaudeCliInvocation.mockClear(); @@ -407,3 +415,195 @@ describe('claude-integration-handler', () => { expect(mockPersistSession).not.toHaveBeenCalled(); }); }); + +/** + * Unit tests for helper functions + */ +describe('claude-integration-handler - Helper Functions', () => { + describe('buildClaudeShellCommand', () => { + it('should build default command without cwd or PATH prefix', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand('', '', "'/opt/bin/claude'", { method: 'default' }); + + expect(result).toBe("'/opt/bin/claude'\r"); + }); + + it('should build command with cwd', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand("cd '/tmp/project' && ", '', "'/opt/bin/claude'", { method: 'default' }); + + expect(result).toBe("cd '/tmp/project' && '/opt/bin/claude'\r"); + }); + + it('should build command with PATH prefix', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand('', "PATH='/custom/path' ", "'/opt/bin/claude'", { method: 'default' }); + + expect(result).toBe("PATH='/custom/path' '/opt/bin/claude'\r"); + }); + + it('should build temp-file method command with history-safe prefixes', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand( + "cd '/tmp/project' && ", + "PATH='/opt/bin' ", + "'/opt/bin/claude'", + { method: 'temp-file', escapedTempFile: "'/tmp/.token-123'" } + ); + + expect(result).toContain('clear && '); + expect(result).toContain("cd '/tmp/project' && "); + expect(result).toContain('HISTFILE= HISTCONTROL=ignorespace'); + expect(result).toContain("PATH='/opt/bin' "); + expect(result).toContain("source '/tmp/.token-123'"); + expect(result).toContain("rm -f '/tmp/.token-123'"); + expect(result).toContain("exec '/opt/bin/claude'"); + }); + + it('should build config-dir method command with CLAUDE_CONFIG_DIR', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand( + "cd '/tmp/project' && ", + "PATH='/opt/bin' ", + "'/opt/bin/claude'", + { method: 'config-dir', escapedConfigDir: "'/home/user/.claude-work'" } + ); + + expect(result).toContain('clear && '); + expect(result).toContain("cd '/tmp/project' && "); + expect(result).toContain('HISTFILE= HISTCONTROL=ignorespace'); + expect(result).toContain("CLAUDE_CONFIG_DIR='/home/user/.claude-work'"); + expect(result).toContain("PATH='/opt/bin' "); + expect(result).toContain("exec '/opt/bin/claude'"); + }); + + it('should handle empty cwdCommand for temp-file method', async () => { + const { buildClaudeShellCommand } = await import('../claude-integration-handler'); + const result = buildClaudeShellCommand( + '', + '', + "'/opt/bin/claude'", + { method: 'temp-file', escapedTempFile: "'/tmp/.token'" } + ); + + expect(result).toContain('clear && '); + expect(result).toContain('HISTFILE= HISTCONTROL=ignorespace'); + expect(result).not.toContain('cd '); + expect(result).toContain("source '/tmp/.token'"); + }); + }); + + describe('finalizeClaudeInvoke', () => { + it('should set terminal title to "Claude" for default profile', async () => { + const { finalizeClaudeInvoke } = await import('../claude-integration-handler'); + const terminal = createMockTerminal(); + const mockWindow = { + webContents: { send: vi.fn() } + }; + + finalizeClaudeInvoke( + terminal, + { name: 'Default', isDefault: true }, + '/tmp/project', + Date.now(), + () => mockWindow as any, + vi.fn() + ); + + expect(terminal.title).toBe('Claude'); + }); + + it('should set terminal title to "Claude (ProfileName)" for non-default profile', async () => { + const { finalizeClaudeInvoke } = await import('../claude-integration-handler'); + const terminal = createMockTerminal(); + const mockWindow = { + webContents: { send: vi.fn() } + }; + + finalizeClaudeInvoke( + terminal, + { name: 'Work Profile', isDefault: false }, + '/tmp/project', + Date.now(), + () => mockWindow as any, + vi.fn() + ); + + expect(terminal.title).toBe('Claude (Work Profile)'); + }); + + it('should send IPC message to renderer', async () => { + const { finalizeClaudeInvoke } = await import('../claude-integration-handler'); + const terminal = createMockTerminal(); + const mockSend = vi.fn(); + const mockWindow = { + webContents: { send: mockSend } + }; + + finalizeClaudeInvoke( + terminal, + undefined, + '/tmp/project', + Date.now(), + () => mockWindow as any, + vi.fn() + ); + + expect(mockSend).toHaveBeenCalledWith( + expect.stringContaining('title'), + terminal.id, + 'Claude' + ); + }); + + it('should persist session when terminal has projectPath', async () => { + const { finalizeClaudeInvoke } = await import('../claude-integration-handler'); + const terminal = createMockTerminal({ projectPath: '/tmp/project' }); + + finalizeClaudeInvoke( + terminal, + undefined, + '/tmp/project', + Date.now(), + () => null, + vi.fn() + ); + + expect(mockPersistSession).toHaveBeenCalledWith(terminal); + }); + + it('should call onSessionCapture when projectPath is provided', async () => { + const { finalizeClaudeInvoke } = await import('../claude-integration-handler'); + const terminal = createMockTerminal(); + const mockOnSessionCapture = vi.fn(); + const startTime = Date.now(); + + finalizeClaudeInvoke( + terminal, + undefined, + '/tmp/project', + startTime, + () => null, + mockOnSessionCapture + ); + + expect(mockOnSessionCapture).toHaveBeenCalledWith(terminal.id, '/tmp/project', startTime); + }); + + it('should not crash when getWindow returns null', async () => { + const { finalizeClaudeInvoke } = await import('../claude-integration-handler'); + const terminal = createMockTerminal(); + + expect(() => { + finalizeClaudeInvoke( + terminal, + undefined, + '/tmp/project', + Date.now(), + () => null, + vi.fn() + ); + }).not.toThrow(); + }); + }); +}); diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index 27b6865a..ae420b2d 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -5,15 +5,16 @@ import * as os from 'os'; import * as fs from 'fs'; +import { promises as fsPromises } from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; import { IPC_CHANNELS } from '../../shared/constants'; -import { getClaudeProfileManager } from '../claude-profile-manager'; +import { getClaudeProfileManager, initializeClaudeProfileManager } from '../claude-profile-manager'; import * as OutputParser from './output-parser'; import * as SessionHandler from './session-handler'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; import { escapeShellArg, buildCdCommand } from '../../shared/utils/shell-escape'; -import { getClaudeCliInvocation } from '../claude-cli-utils'; +import { getClaudeCliInvocation, getClaudeCliInvocationAsync } from '../claude-cli-utils'; import type { TerminalProcess, WindowGetter, @@ -25,6 +26,133 @@ function normalizePathForBash(envPath: string): string { return process.platform === 'win32' ? envPath.replace(/;/g, ':') : envPath; } +// ============================================================================ +// SHARED HELPERS - Used by both sync and async invokeClaude +// ============================================================================ + +/** + * Configuration for building Claude shell commands using discriminated union. + * This provides type safety by ensuring the correct options are provided for each method. + */ +type ClaudeCommandConfig = + | { method: 'default' } + | { method: 'temp-file'; escapedTempFile: string } + | { method: 'config-dir'; escapedConfigDir: string }; + +/** + * Build the shell command for invoking Claude CLI. + * + * Generates the appropriate command string based on the invocation method: + * - 'default': Simple command execution + * - 'temp-file': Sources OAuth token from temp file, then removes it + * - 'config-dir': Sets CLAUDE_CONFIG_DIR for custom profile location + * + * All non-default methods include history-safe prefixes (HISTFILE=, HISTCONTROL=) + * to prevent sensitive data from appearing in shell history. + * + * @param cwdCommand - Command to change directory (empty string if no change needed) + * @param pathPrefix - PATH prefix for Claude CLI (empty string if not needed) + * @param escapedClaudeCmd - Shell-escaped Claude CLI command + * @param config - Configuration object with method and required options (discriminated union) + * @returns Complete shell command string ready for terminal.pty.write() + * + * @example + * // Default method + * buildClaudeShellCommand('cd /path && ', 'PATH=/bin ', 'claude', { method: 'default' }); + * // Returns: 'cd /path && PATH=/bin claude\r' + * + * // Temp file method + * buildClaudeShellCommand('', '', 'claude', { method: 'temp-file', escapedTempFile: '/tmp/token' }); + * // Returns: 'clear && HISTFILE= HISTCONTROL=ignorespace bash -c "source /tmp/token && rm -f /tmp/token && exec claude"\r' + */ +export function buildClaudeShellCommand( + cwdCommand: string, + pathPrefix: string, + escapedClaudeCmd: string, + config: ClaudeCommandConfig +): string { + switch (config.method) { + case 'temp-file': + return `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace ${pathPrefix}bash -c "source ${config.escapedTempFile} && rm -f ${config.escapedTempFile} && exec ${escapedClaudeCmd}"\r`; + + case 'config-dir': + return `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace CLAUDE_CONFIG_DIR=${config.escapedConfigDir} ${pathPrefix}bash -c "exec ${escapedClaudeCmd}"\r`; + + default: + return `${cwdCommand}${pathPrefix}${escapedClaudeCmd}\r`; + } +} + +/** + * Profile information for terminal title generation + */ +interface ProfileInfo { + /** Profile name for display */ + name?: string; + /** Whether this is the default profile */ + isDefault?: boolean; +} + +/** + * Callback type for session capture + */ +type SessionCaptureCallback = (terminalId: string, projectPath: string, startTime: number) => void; + +/** + * Finalize terminal state after invoking Claude. + * + * Updates terminal title, sends IPC notification to renderer, persists session, + * and calls the session capture callback. This consolidates the post-invocation + * logic used by both sync and async invoke methods. + * + * @param terminal - The terminal process to update + * @param activeProfile - The profile being used (or undefined for default) + * @param projectPath - The project path (for session capture) + * @param startTime - Timestamp when invocation started + * @param getWindow - Function to get the BrowserWindow + * @param onSessionCapture - Callback for session capture + * + * @example + * finalizeClaudeInvoke( + * terminal, + * { name: 'Work', isDefault: false }, + * '/path/to/project', + * Date.now(), + * () => mainWindow, + * (id, path, time) => console.log('Session captured') + * ); + */ +export function finalizeClaudeInvoke( + terminal: TerminalProcess, + activeProfile: ProfileInfo | undefined, + projectPath: string | undefined, + startTime: number, + getWindow: WindowGetter, + onSessionCapture: SessionCaptureCallback +): void { + // Set terminal title based on profile + const title = activeProfile && !activeProfile.isDefault + ? `Claude (${activeProfile.name})` + : 'Claude'; + terminal.title = title; + + // Notify renderer of title change + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, title); + } + + // Persist session if project path is available + if (terminal.projectPath) { + SessionHandler.persistSession(terminal); + } + + // Call session capture callback if project path provided + if (projectPath) { + onSessionCapture(terminal.id, projectPath, startTime); + } +} + /** * Handle rate limit detection and profile switching */ @@ -217,7 +345,6 @@ export function invokeClaude( debugLog('[ClaudeIntegration:invokeClaude] CWD:', cwd); terminal.isClaudeMode = true; - // Release any previously claimed session ID before starting new session SessionHandler.releaseSessionId(terminal.id); terminal.claudeSessionId = undefined; @@ -240,7 +367,6 @@ export function invokeClaude( isDefault: activeProfile?.isDefault }); - // Use safe shell escaping to prevent command injection const cwdCommand = buildCdCommand(cwd); const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation(); const escapedClaudeCmd = escapeShellArg(claudeCmd); @@ -273,58 +399,20 @@ export function invokeClaude( { mode: 0o600 } ); - // Clear terminal and run command without adding to shell history: - // - HISTFILE= disables history file writing for the current command - // - HISTCONTROL=ignorespace causes commands starting with space to be ignored - // - Leading space ensures the command is ignored even if HISTCONTROL was already set - // - Uses subshell (...) to isolate environment changes - // This prevents temp file paths from appearing in shell history - const command = `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace ${pathPrefix}bash -c "source ${escapedTempFile} && rm -f ${escapedTempFile} && exec ${escapedClaudeCmd}"\r`; + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', escapedTempFile }); debugLog('[ClaudeIntegration:invokeClaude] Executing command (temp file method, history-safe)'); terminal.pty.write(command); profileManager.markProfileUsed(activeProfile.id); - - // Update terminal title and persist session - const title = `Claude (${activeProfile.name})`; - terminal.title = title; - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, title); - } - if (terminal.projectPath) { - SessionHandler.persistSession(terminal); - } - if (projectPath) { - onSessionCapture(terminal.id, projectPath, startTime); - } - + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (temp file) =========='); return; } else if (activeProfile.configDir) { - // Clear terminal and run command without adding to shell history: - // Same history-disabling technique as temp file method above - // SECURITY: Use escapeShellArg for configDir to prevent command injection - // Set CLAUDE_CONFIG_DIR as env var before bash -c to avoid embedding user input in the command string const escapedConfigDir = escapeShellArg(activeProfile.configDir); - const command = `clear && ${cwdCommand}HISTFILE= HISTCONTROL=ignorespace CLAUDE_CONFIG_DIR=${escapedConfigDir} ${pathPrefix}bash -c "exec ${escapedClaudeCmd}"\r`; + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', escapedConfigDir }); debugLog('[ClaudeIntegration:invokeClaude] Executing command (configDir method, history-safe)'); terminal.pty.write(command); profileManager.markProfileUsed(activeProfile.id); - - // Update terminal title and persist session - const title = `Claude (${activeProfile.name})`; - terminal.title = title; - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, title); - } - if (terminal.projectPath) { - SessionHandler.persistSession(terminal); - } - if (projectPath) { - onSessionCapture(terminal.id, projectPath, startTime); - } - + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (configDir) =========='); return; } else { @@ -336,7 +424,7 @@ export function invokeClaude( debugLog('[ClaudeIntegration:invokeClaude] Using terminal environment for non-default profile:', activeProfile.name); } - const command = `${cwdCommand}${pathPrefix}${escapedClaudeCmd}\r`; + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' }); debugLog('[ClaudeIntegration:invokeClaude] Executing command (default method):', command); terminal.pty.write(command); @@ -344,25 +432,7 @@ export function invokeClaude( profileManager.markProfileUsed(activeProfile.id); } - // Update terminal title in main process and notify renderer - const title = activeProfile && !activeProfile.isDefault - ? `Claude (${activeProfile.name})` - : 'Claude'; - terminal.title = title; - - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, title); - } - - if (terminal.projectPath) { - SessionHandler.persistSession(terminal); - } - - if (projectPath) { - onSessionCapture(terminal.id, projectPath, startTime); - } - + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); debugLog('[ClaudeIntegration:invokeClaude] ========== INVOKE CLAUDE COMPLETE (default) =========='); } @@ -421,6 +491,171 @@ export function resumeClaude( } } +// ============================================================================ +// ASYNC VERSIONS - Non-blocking alternatives for Electron main process +// ============================================================================ + +/** + * Invoke Claude asynchronously (non-blocking) + * + * Safe to call from Electron main process without blocking the event loop. + * Uses async CLI detection which doesn't block on subprocess calls. + */ +export async function invokeClaudeAsync( + terminal: TerminalProcess, + cwd: string | undefined, + profileId: string | undefined, + getWindow: WindowGetter, + onSessionCapture: (terminalId: string, projectPath: string, startTime: number) => void +): Promise { + debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE START (async) =========='); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Terminal ID:', terminal.id); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Requested profile ID:', profileId); + debugLog('[ClaudeIntegration:invokeClaudeAsync] CWD:', cwd); + + terminal.isClaudeMode = true; + SessionHandler.releaseSessionId(terminal.id); + terminal.claudeSessionId = undefined; + + const startTime = Date.now(); + const projectPath = cwd || terminal.projectPath || terminal.cwd; + + // Ensure profile manager is initialized (async, yields to event loop) + const profileManager = await initializeClaudeProfileManager(); + const activeProfile = profileId + ? profileManager.getProfile(profileId) + : profileManager.getActiveProfile(); + + const previousProfileId = terminal.claudeProfileId; + terminal.claudeProfileId = activeProfile?.id; + + debugLog('[ClaudeIntegration:invokeClaudeAsync] Profile resolution:', { + previousProfileId, + newProfileId: activeProfile?.id, + profileName: activeProfile?.name, + hasOAuthToken: !!activeProfile?.oauthToken, + isDefault: activeProfile?.isDefault + }); + + // Async CLI invocation - non-blocking + const cwdCommand = buildCdCommand(cwd); + const { command: claudeCmd, env: claudeEnv } = await getClaudeCliInvocationAsync(); + const escapedClaudeCmd = escapeShellArg(claudeCmd); + const pathPrefix = claudeEnv.PATH + ? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} ` + : ''; + const needsEnvOverride = profileId && profileId !== previousProfileId; + + debugLog('[ClaudeIntegration:invokeClaudeAsync] Environment override check:', { + profileIdProvided: !!profileId, + previousProfileId, + needsEnvOverride + }); + + if (needsEnvOverride && activeProfile && !activeProfile.isDefault) { + const token = profileManager.getProfileToken(activeProfile.id); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Token retrieval:', { + hasToken: !!token, + tokenLength: token?.length + }); + + if (token) { + const nonce = crypto.randomBytes(8).toString('hex'); + const tempFile = path.join(os.tmpdir(), `.claude-token-${Date.now()}-${nonce}`); + const escapedTempFile = escapeShellArg(tempFile); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Writing token to temp file:', tempFile); + await fsPromises.writeFile( + tempFile, + `export CLAUDE_CODE_OAUTH_TOKEN=${escapeShellArg(token)}\n`, + { mode: 0o600 } + ); + + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'temp-file', escapedTempFile }); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (temp file method, history-safe)'); + terminal.pty.write(command); + profileManager.markProfileUsed(activeProfile.id); + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); + debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE COMPLETE (temp file) =========='); + return; + } else if (activeProfile.configDir) { + const escapedConfigDir = escapeShellArg(activeProfile.configDir); + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'config-dir', escapedConfigDir }); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (configDir method, history-safe)'); + terminal.pty.write(command); + profileManager.markProfileUsed(activeProfile.id); + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); + debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE COMPLETE (configDir) =========='); + return; + } else { + debugLog('[ClaudeIntegration:invokeClaudeAsync] WARNING: No token or configDir available for non-default profile'); + } + } + + if (activeProfile && !activeProfile.isDefault) { + debugLog('[ClaudeIntegration:invokeClaudeAsync] Using terminal environment for non-default profile:', activeProfile.name); + } + + const command = buildClaudeShellCommand(cwdCommand, pathPrefix, escapedClaudeCmd, { method: 'default' }); + debugLog('[ClaudeIntegration:invokeClaudeAsync] Executing command (default method):', command); + terminal.pty.write(command); + + if (activeProfile) { + profileManager.markProfileUsed(activeProfile.id); + } + + finalizeClaudeInvoke(terminal, activeProfile, projectPath, startTime, getWindow, onSessionCapture); + debugLog('[ClaudeIntegration:invokeClaudeAsync] ========== INVOKE CLAUDE COMPLETE (default) =========='); +} + +/** + * Resume Claude asynchronously (non-blocking) + * + * Safe to call from Electron main process without blocking the event loop. + * Uses async CLI detection which doesn't block on subprocess calls. + */ +export async function resumeClaudeAsync( + terminal: TerminalProcess, + sessionId: string | undefined, + getWindow: WindowGetter +): Promise { + terminal.isClaudeMode = true; + SessionHandler.releaseSessionId(terminal.id); + + // Async CLI invocation - non-blocking + const { command: claudeCmd, env: claudeEnv } = await getClaudeCliInvocationAsync(); + const escapedClaudeCmd = escapeShellArg(claudeCmd); + const pathPrefix = claudeEnv.PATH + ? `PATH=${escapeShellArg(normalizePathForBash(claudeEnv.PATH))} ` + : ''; + + // Always use --continue which resumes the most recent session in the current directory. + // This is more reliable than --resume with session IDs since Auto Claude already restores + // terminals to their correct cwd/projectPath. + // + // Note: We clear claudeSessionId because --continue doesn't track specific sessions, + // and we don't want stale IDs persisting through SessionHandler.persistSession(). + terminal.claudeSessionId = undefined; + + // Deprecation warning for callers still passing sessionId + if (sessionId) { + console.warn('[ClaudeIntegration:resumeClaudeAsync] sessionId parameter is deprecated and ignored; using claude --continue instead'); + } + + const command = `${pathPrefix}${escapedClaudeCmd} --continue`; + + terminal.pty.write(`${command}\r`); + + terminal.title = 'Claude'; + const win = getWindow(); + if (win) { + win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, 'Claude'); + } + + if (terminal.projectPath) { + SessionHandler.persistSession(terminal); + } +} + /** * Configuration for waiting for Claude to exit */ @@ -525,7 +760,7 @@ export async function switchClaudeProfile( terminal: TerminalProcess, profileId: string, getWindow: WindowGetter, - invokeClaudeCallback: (terminalId: string, cwd: string | undefined, profileId: string) => void, + invokeClaudeCallback: (terminalId: string, cwd: string | undefined, profileId: string) => Promise, clearRateLimitCallback: (terminalId: string) => void ): Promise<{ success: boolean; error?: string }> { // Always-on tracing @@ -543,7 +778,8 @@ export async function switchClaudeProfile( cwd: terminal.cwd }); - const profileManager = getClaudeProfileManager(); + // Ensure profile manager is initialized (async, yields to event loop) + const profileManager = await initializeClaudeProfileManager(); const profile = profileManager.getProfile(profileId); console.warn('[ClaudeIntegration:switchClaudeProfile] Profile found:', profile?.name || 'NOT FOUND'); @@ -611,7 +847,7 @@ export async function switchClaudeProfile( projectPath, profileId }); - invokeClaudeCallback(terminal.id, projectPath, profileId); + await invokeClaudeCallback(terminal.id, projectPath, profileId); debugLog('[ClaudeIntegration:switchClaudeProfile] Setting active profile in profile manager'); profileManager.setActiveProfile(profileId); diff --git a/apps/frontend/src/main/terminal/terminal-manager.ts b/apps/frontend/src/main/terminal/terminal-manager.ts index 72e7b983..2f86c218 100644 --- a/apps/frontend/src/main/terminal/terminal-manager.ts +++ b/apps/frontend/src/main/terminal/terminal-manager.ts @@ -82,7 +82,10 @@ export class TerminalManager { ); }, onResumeNeeded: (terminalId, sessionId) => { - this.resumeClaude(terminalId, sessionId); + // Use async version to avoid blocking main process + this.resumeClaudeAsync(terminalId, sessionId).catch((error) => { + console.error('[terminal-manager] Failed to resume Claude session:', error); + }); } }, cols, @@ -133,8 +136,35 @@ export class TerminalManager { } } + /** + * Invoke Claude in a terminal with optional profile override (async - non-blocking) + */ + async invokeClaudeAsync(id: string, cwd?: string, profileId?: string): Promise { + const terminal = this.terminals.get(id); + if (!terminal) { + return; + } + + await ClaudeIntegration.invokeClaudeAsync( + terminal, + cwd, + profileId, + this.getWindow, + (terminalId, projectPath, startTime) => { + SessionHandler.captureClaudeSessionId( + terminalId, + projectPath, + startTime, + this.terminals, + this.getWindow + ); + } + ); + } + /** * Invoke Claude in a terminal with optional profile override + * @deprecated Use invokeClaudeAsync for non-blocking behavior */ invokeClaude(id: string, cwd?: string, profileId?: string): void { const terminal = this.terminals.get(id); @@ -172,13 +202,26 @@ export class TerminalManager { terminal, profileId, this.getWindow, - (terminalId, cwd, profileId) => this.invokeClaude(terminalId, cwd, profileId), + async (terminalId, cwd, profileId) => this.invokeClaudeAsync(terminalId, cwd, profileId), (terminalId) => this.lastNotifiedRateLimitReset.delete(terminalId) ); } + /** + * Resume Claude in a terminal asynchronously (non-blocking) + */ + async resumeClaudeAsync(id: string, sessionId?: string): Promise { + const terminal = this.terminals.get(id); + if (!terminal) { + return; + } + + await ClaudeIntegration.resumeClaudeAsync(terminal, sessionId, this.getWindow); + } + /** * Resume Claude in a terminal with a specific session ID + * @deprecated Use resumeClaudeAsync for non-blocking behavior */ resumeClaude(id: string, sessionId?: string): void { const terminal = this.terminals.get(id); @@ -244,7 +287,10 @@ export class TerminalManager { ); }, onResumeNeeded: (terminalId, sessionId) => { - this.resumeClaude(terminalId, sessionId); + // Use async version to avoid blocking main process + this.resumeClaudeAsync(terminalId, sessionId).catch((error) => { + console.error('[terminal-manager] Failed to resume Claude session:', error); + }); } }, cols, diff --git a/apps/frontend/src/main/utils/windows-paths.ts b/apps/frontend/src/main/utils/windows-paths.ts index 27ce4f9b..355640ac 100644 --- a/apps/frontend/src/main/utils/windows-paths.ts +++ b/apps/frontend/src/main/utils/windows-paths.ts @@ -9,10 +9,14 @@ */ import { existsSync } from 'fs'; -import { execFileSync } from 'child_process'; +import { access, constants } from 'fs/promises'; +import { execFileSync, execFile } from 'child_process'; +import { promisify } from 'util'; import path from 'path'; import os from 'os'; +const execFileAsync = promisify(execFile); + export interface WindowsToolPaths { toolName: string; executable: string; @@ -170,3 +174,110 @@ export function findWindowsExecutableViaWhere( return null; } } + +/** + * Async version of getWindowsExecutablePaths. + * Use this in async contexts to avoid blocking the main process. + */ +export async function getWindowsExecutablePathsAsync( + toolPaths: WindowsToolPaths, + logPrefix: string = '[Windows Paths]' +): Promise { + // Only run on Windows + if (process.platform !== 'win32') { + return []; + } + + const validPaths: string[] = []; + + for (const pattern of toolPaths.patterns) { + const expandedDir = expandWindowsPath(pattern); + + if (!expandedDir) { + console.warn(`${logPrefix} Could not expand path pattern: ${pattern}`); + continue; + } + + const fullPath = path.join(expandedDir, toolPaths.executable); + + // Security validation - reject potentially dangerous paths + if (!isSecurePath(fullPath)) { + console.warn(`${logPrefix} Path failed security validation: ${fullPath}`); + continue; + } + + try { + await access(fullPath, constants.F_OK); + validPaths.push(fullPath); + } catch { + // File doesn't exist, skip + } + } + + return validPaths; +} + +/** + * Async version of findWindowsExecutableViaWhere. + * Use this in async contexts to avoid blocking the main process. + * + * Find a Windows executable using the `where` command. + * This is the most reliable method as it searches: + * - All directories in PATH + * - App Paths registry entries + * - Current directory + * + * Works regardless of where the tool is installed (custom paths, different drives, etc.) + * + * @param executable - The executable name (e.g., 'git', 'gh', 'python') + * @param logPrefix - Prefix for console logging + * @returns The full path to the executable, or null if not found + */ +export async function findWindowsExecutableViaWhereAsync( + executable: string, + logPrefix: string = '[Windows Where]' +): Promise { + if (process.platform !== 'win32') { + return null; + } + + // Security: Only allow simple executable names (alphanumeric, dash, underscore, dot) + if (!/^[\w.-]+$/.test(executable)) { + console.warn(`${logPrefix} Invalid executable name: ${executable}`); + return null; + } + + try { + // Use 'where' command to find the executable + // where.exe is a built-in Windows command that finds executables + const { stdout } = await execFileAsync('where.exe', [executable], { + encoding: 'utf-8', + timeout: 5000, + windowsHide: true, + }); + + // 'where' returns multiple paths separated by newlines if found in multiple locations + // We take the first one (highest priority in PATH) + const paths = stdout.trim().split(/\r?\n/).filter(p => p.trim()); + + if (paths.length > 0) { + const foundPath = paths[0].trim(); + + // Validate the path exists and is secure + try { + await access(foundPath, constants.F_OK); + if (isSecurePath(foundPath)) { + console.log(`${logPrefix} Found via where: ${foundPath}`); + return foundPath; + } + } catch { + // Path doesn't exist + } + } + + return null; + } catch { + // 'where' returns exit code 1 if not found, which throws an error + return null; + } +} diff --git a/package-lock.json b/package-lock.json index d16fa47c..842c9c6a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -118,6 +118,98 @@ "npm": ">=10.0.0" } }, + "apps/frontend/node_modules/@lydell/node-pty": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz", + "integrity": "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw==", + "license": "MIT", + "optionalDependencies": { + "@lydell/node-pty-darwin-arm64": "1.1.0", + "@lydell/node-pty-darwin-x64": "1.1.0", + "@lydell/node-pty-linux-arm64": "1.1.0", + "@lydell/node-pty-linux-x64": "1.1.0", + "@lydell/node-pty-win32-arm64": "1.1.0", + "@lydell/node-pty-win32-x64": "1.1.0" + } + }, + "apps/frontend/node_modules/@lydell/node-pty-darwin-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.1.0.tgz", + "integrity": "sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "apps/frontend/node_modules/@lydell/node-pty-darwin-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.1.0.tgz", + "integrity": "sha512-XZdvqj5FjAMjH8bdp0YfaZjur5DrCIDD1VYiE9EkkYVMDQqRUPHYV3U8BVEQVT9hYfjmpr7dNaELF2KyISWSNA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "apps/frontend/node_modules/@lydell/node-pty-linux-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.1.0.tgz", + "integrity": "sha512-yyDBmalCfHpLiQMT2zyLcqL2Fay4Xy7rIs8GH4dqKLnEviMvPGOK7LADVkKAsbsyXBSISL3Lt1m1MtxhPH6ckg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "apps/frontend/node_modules/@lydell/node-pty-linux-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.1.0.tgz", + "integrity": "sha512-NcNqRTD14QT+vXcEuqSSvmWY+0+WUBn2uRE8EN0zKtDpIEr9d+YiFj16Uqds6QfcLCHfZmC+Ls7YzwTaqDnanA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "apps/frontend/node_modules/@lydell/node-pty-win32-arm64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.1.0.tgz", + "integrity": "sha512-JOMbCou+0fA7d/m97faIIfIU0jOv8sn2OR7tI45u3AmldKoKoLP8zHY6SAvDDnI3fccO1R2HeR1doVjpS7HM0w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "apps/frontend/node_modules/@lydell/node-pty-win32-x64": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.1.0.tgz", + "integrity": "sha512-3N56BZ+WDFnUMYRtsrr7Ky2mhWGl9xXcyqR6cexfuCqcz9RNWL+KoXRv/nZylY5dYaXkft4JaR1uVu+roiZDAw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@acemir/cssom": { "version": "0.9.30", "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.30.tgz", @@ -2563,98 +2655,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@lydell/node-pty": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lydell/node-pty/-/node-pty-1.1.0.tgz", - "integrity": "sha512-VDD8LtlMTOrPKWMXUAcB9+LTktzuunqrMwkYR1DMRBkS6LQrCt+0/Ws1o2rMml/n3guePpS7cxhHF7Nm5K4iMw==", - "license": "MIT", - "optionalDependencies": { - "@lydell/node-pty-darwin-arm64": "1.1.0", - "@lydell/node-pty-darwin-x64": "1.1.0", - "@lydell/node-pty-linux-arm64": "1.1.0", - "@lydell/node-pty-linux-x64": "1.1.0", - "@lydell/node-pty-win32-arm64": "1.1.0", - "@lydell/node-pty-win32-x64": "1.1.0" - } - }, - "node_modules/@lydell/node-pty-darwin-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-arm64/-/node-pty-darwin-arm64-1.1.0.tgz", - "integrity": "sha512-7kFD+owAA61qmhJCtoMbqj3Uvff3YHDiU+4on5F2vQdcMI3MuwGi7dM6MkFG/yuzpw8LF2xULpL71tOPUfxs0w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@lydell/node-pty-darwin-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lydell/node-pty-darwin-x64/-/node-pty-darwin-x64-1.1.0.tgz", - "integrity": "sha512-XZdvqj5FjAMjH8bdp0YfaZjur5DrCIDD1VYiE9EkkYVMDQqRUPHYV3U8BVEQVT9hYfjmpr7dNaELF2KyISWSNA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@lydell/node-pty-linux-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-arm64/-/node-pty-linux-arm64-1.1.0.tgz", - "integrity": "sha512-yyDBmalCfHpLiQMT2zyLcqL2Fay4Xy7rIs8GH4dqKLnEviMvPGOK7LADVkKAsbsyXBSISL3Lt1m1MtxhPH6ckg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@lydell/node-pty-linux-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lydell/node-pty-linux-x64/-/node-pty-linux-x64-1.1.0.tgz", - "integrity": "sha512-NcNqRTD14QT+vXcEuqSSvmWY+0+WUBn2uRE8EN0zKtDpIEr9d+YiFj16Uqds6QfcLCHfZmC+Ls7YzwTaqDnanA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@lydell/node-pty-win32-arm64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-arm64/-/node-pty-win32-arm64-1.1.0.tgz", - "integrity": "sha512-JOMbCou+0fA7d/m97faIIfIU0jOv8sn2OR7tI45u3AmldKoKoLP8zHY6SAvDDnI3fccO1R2HeR1doVjpS7HM0w==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@lydell/node-pty-win32-x64": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@lydell/node-pty-win32-x64/-/node-pty-win32-x64-1.1.0.tgz", - "integrity": "sha512-3N56BZ+WDFnUMYRtsrr7Ky2mhWGl9xXcyqR6cexfuCqcz9RNWL+KoXRv/nZylY5dYaXkft4JaR1uVu+roiZDAw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz",