/** * IPC Handlers for Utility Operations. * * Handlers: * - shell:openPath: Opens a folder or file in the system's default application * - read-claude-md-files: Reads all global CLAUDE.md files for a project * - read-directory-claude-md: Reads a specific directory's CLAUDE.md file * - read-mentioned-file: Validates mentioned files for context injection */ import { createLogger } from '@shared/utils/logger'; import { app, type IpcMain, type IpcMainInvokeEvent, shell } from 'electron'; import * as fsp from 'fs/promises'; import { type ClaudeMdFileInfo, readAgentConfigs, readAllClaudeMdFiles, readDirectoryClaudeMd, } from '../services'; import type { AgentConfig } from '@shared/types/api'; const logger = createLogger('IPC:utility'); import { validateFilePath, validateOpenPath, validateOpenPathUserSelected, } from '../utils/pathValidation'; import { countTokens } from '../utils/tokenizer'; /** * Registers all utility-related IPC handlers. */ export function registerUtilityHandlers(ipcMain: IpcMain): void { ipcMain.handle('get-app-version', handleGetAppVersion); ipcMain.handle('shell:openPath', handleShellOpenPath); ipcMain.handle('shell:showInFolder', handleShellShowInFolder); ipcMain.handle('shell:openExternal', handleShellOpenExternal); ipcMain.handle('read-claude-md-files', handleReadClaudeMdFiles); ipcMain.handle('read-directory-claude-md', handleReadDirectoryClaudeMd); ipcMain.handle('read-mentioned-file', handleReadMentionedFile); ipcMain.handle('read-agent-configs', handleReadAgentConfigs); logger.info('Utility handlers registered'); } /** * Removes all utility IPC handlers. */ export function removeUtilityHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler('get-app-version'); ipcMain.removeHandler('shell:openPath'); ipcMain.removeHandler('shell:showInFolder'); ipcMain.removeHandler('shell:openExternal'); ipcMain.removeHandler('read-claude-md-files'); ipcMain.removeHandler('read-directory-claude-md'); ipcMain.removeHandler('read-mentioned-file'); ipcMain.removeHandler('read-agent-configs'); logger.info('Utility handlers removed'); } // ============================================================================= // Handler Implementations // ============================================================================= /** * Handler for 'get-app-version' IPC call. * Returns the app version from package.json. */ function handleGetAppVersion(): string { return app.getVersion(); } /** * Handler for 'shell:showInFolder' IPC call. * Reveals a file in the system file manager (Finder/Explorer). */ async function handleShellShowInFolder( _event: IpcMainInvokeEvent, filePath: string ): Promise { if (typeof filePath !== 'string' || filePath.length === 0) return; try { await fsp.access(filePath); shell.showItemInFolder(filePath); } catch { // File doesn't exist — silently ignore } } /** * Handler for 'shell:openExternal' IPC call. * Opens a URL in the system's default browser. */ async function handleShellOpenExternal( _event: IpcMainInvokeEvent, url: string ): Promise<{ success: boolean; error?: string }> { try { let parsedUrl: URL; try { parsedUrl = new URL(url); } catch { return { success: false, error: 'Invalid URL' }; } const protocol = parsedUrl.protocol.toLowerCase(); if (protocol !== 'http:' && protocol !== 'https:' && protocol !== 'mailto:') { logger.error(`shell:openExternal - invalid URL scheme: ${url}`); return { success: false, error: 'Only http, https, and mailto URLs are allowed' }; } await shell.openExternal(parsedUrl.toString()); return { success: true }; } catch (error) { logger.error('Error in shell:openExternal:', error); return { success: false, error: String(error) }; } } /** * Handler for 'shell:openPath' IPC call. * Opens a folder or file in the system's default application (Finder on macOS). * Validates path security before opening. * When userSelectedFromDialog is true, path was chosen via system folder picker — * only sensitive-pattern checks apply, not project/claude directory restriction. */ async function handleShellOpenPath( _event: IpcMainInvokeEvent, targetPath: string, projectRoot?: string, userSelectedFromDialog?: boolean ): Promise<{ success: boolean; error?: string }> { try { const validation = userSelectedFromDialog ? validateOpenPathUserSelected(targetPath) : validateOpenPath(targetPath, projectRoot ?? null); if (!validation.valid) { logger.error(`shell:openPath - validation failed: ${validation.error ?? 'Unknown error'}`); return { success: false, error: validation.error }; } const safePath = validation.normalizedPath!; // Check if path exists (async to avoid blocking main thread) try { await fsp.access(safePath); } catch { logger.error(`shell:openPath - path does not exist: ${safePath}`); return { success: false, error: 'Path does not exist' }; } // Open in default application (Finder on macOS) const errorMessage = await shell.openPath(safePath); if (errorMessage) { logger.error(`shell:openPath - failed: ${errorMessage}`); return { success: false, error: errorMessage }; } return { success: true }; } catch (error) { logger.error('Error in shell:openPath:', error); return { success: false, error: String(error) }; } } /** * Handler for 'read-claude-md-files' IPC call. * Reads all global CLAUDE.md files for a project. */ async function handleReadClaudeMdFiles( _event: IpcMainInvokeEvent, projectRoot: string ): Promise> { try { const result = await readAllClaudeMdFiles(projectRoot); // Convert Map to object for IPC serialization const files: Record = {}; result.files.forEach((info, key) => { files[key] = info; }); return files; } catch (error) { logger.error(`Error in read-claude-md-files:`, error); return {}; } } /** * Handler for 'read-directory-claude-md' IPC call. * Reads a specific directory's CLAUDE.md file. */ async function handleReadDirectoryClaudeMd( _event: IpcMainInvokeEvent, dirPath: string ): Promise { try { const info = await readDirectoryClaudeMd(dirPath); return info; } catch (error) { logger.error(`Error in read-directory-claude-md:`, error); return { path: dirPath, exists: false, charCount: 0, estimatedTokens: 0, }; } } /** * Handler for 'read-mentioned-file' IPC call. * Validates mentioned files for context injection. * Returns file info if file exists, is a regular file, within allowed directories, and within token limits. * * Security: Validates path against allowed directories and sensitive file patterns. */ async function handleReadMentionedFile( _event: IpcMainInvokeEvent, absolutePath: string, projectRoot: string, maxTokens: number = 25000 ): Promise<{ path: string; exists: boolean; charCount: number; estimatedTokens: number } | null> { try { // Validate path security const validation = validateFilePath(absolutePath, projectRoot || null); if (!validation.valid) { return null; } const safePath = validation.normalizedPath!; // Single async stat + read — no TOCTOU, doesn't block main thread const stats = await fsp.stat(safePath); if (!stats.isFile()) { return null; } const content = await fsp.readFile(safePath, 'utf8'); // Calculate tokens const estimatedTokens = countTokens(content); // Check token limit if (estimatedTokens > maxTokens) { return null; } return { path: safePath, exists: true, charCount: content.length, estimatedTokens, }; } catch (error) { // ENOENT is expected — file simply doesn't exist (e.g. stale or misdetected references) if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return null; } logger.error(`Error in read-mentioned-file for ${absolutePath}:`, error); return null; } } /** * Handler for 'read-agent-configs' IPC call. * Reads agent definitions from project's .claude/agents/ directory. */ async function handleReadAgentConfigs( _event: IpcMainInvokeEvent, projectRoot: string ): Promise> { try { return await readAgentConfigs(projectRoot); } catch (error) { logger.error('Error in read-agent-configs:', error); return {}; } }