diff --git a/package.json b/package.json index 218e99287..cddfdf7e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "DeepChat", - "version": "0.4.8", + "version": "0.4.9", "description": "DeepChat,一个简单易用的AI客户端", "main": "./out/main/index.js", "author": "ThinkInAIXYZ", @@ -43,18 +43,18 @@ "build:linux:x64": "pnpm run build && electron-builder --linux --x64", "build:linux:arm64": "pnpm run build && electron-builder --linux --arm64", "afterSign": "scripts/notarize.js", - "installRuntime": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 && npx -y tiny-runtime-injector --type bun --dir ./runtime/bun --runtime-version v1.2.20", + "installRuntime": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 && npx -y tiny-runtime-injector --type node --dir ./runtime/node", "installRuntime:win:x64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a x64 -p win32 && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a x64 -p win32", "installRuntime:win:arm64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a arm64 -p win32 && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a arm64 -p win32", - "installRuntime:mac:arm64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a arm64 -p darwin && npx -y tiny-runtime-injector --type bun --runtime-version v1.2.20 --dir ./runtime/bun -a arm64 -p darwin", - "installRuntime:mac:x64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a x64 -p darwin && npx -y tiny-runtime-injector --type bun --runtime-version v1.2.20 --dir ./runtime/bun -a x64 -p darwin", - "installRuntime:linux:x64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a x64 -p linux && npx -y tiny-runtime-injector --type bun --runtime-version v1.2.20 --dir ./runtime/bun -a x64 -p linux", - "installRuntime:linux:arm64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a arm64 -p linux && npx -y tiny-runtime-injector --type bun --runtime-version v1.2.20 --dir ./runtime/bun -a arm64 -p linux", + "installRuntime:mac:arm64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a arm64 -p darwin && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a arm64 -p darwin", + "installRuntime:mac:x64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a x64 -p darwin && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a x64 -p darwin", + "installRuntime:linux:x64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a x64 -p linux && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a x64 -p linux", + "installRuntime:linux:arm64": "npx -y tiny-runtime-injector --type uv --dir ./runtime/uv --runtime-version 0.8.8 -a arm64 -p linux && npx -y tiny-runtime-injector --type node --dir ./runtime/node -a arm64 -p linux", "installRuntime:duckdb:vss": "node scripts/installVss.js", "i18n": "i18n-check -s zh-CN -f i18next --locales src/renderer/src/i18n", "i18n:en": "i18n-check -s en-US -f i18next --locales src/renderer/src/i18n", "i18n:types": "node scripts/generate-i18n-types.js", - "cleanRuntime": "rm -rf runtime/uv runtime/bun runtime/node", + "cleanRuntime": "rm -rf runtime/uv runtime/node", "update-shadcn": "node scripts/update-shadcn.js" }, "dependencies": { @@ -86,6 +86,7 @@ "jsonrepair": "^3.13.1", "mammoth": "^1.11.0", "nanoid": "^5.1.6", + "node-pty": "^1.0.0", "ollama": "^0.5.18", "openai": "^5.23.2", "pdf-parse-new": "^1.4.1", @@ -175,7 +176,9 @@ "vue-virtual-scroller": "^2.0.0-beta.8", "vuedraggable": "^4.1.0", "yaml": "^2.8.1", - "zod-to-json-schema": "^3.24.6" + "zod-to-json-schema": "^3.24.6", + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0" }, "simple-git-hooks": { "pre-commit": "pnpm lint-staged && pnpm typecheck", diff --git a/src/main/lib/runtimeHelper.ts b/src/main/lib/runtimeHelper.ts new file mode 100644 index 000000000..8ab087bfb --- /dev/null +++ b/src/main/lib/runtimeHelper.ts @@ -0,0 +1,322 @@ +import { app } from 'electron' +import * as fs from 'fs' +import * as path from 'path' + +/** + * RuntimeHelper - Utility class for managing runtime paths and environment variables + * Uses singleton pattern to cache runtime paths and avoid repeated filesystem checks + */ +export class RuntimeHelper { + private static instance: RuntimeHelper | null = null + private nodeRuntimePath: string | null = null + private uvRuntimePath: string | null = null + private runtimesInitialized: boolean = false + + private constructor() { + // Private constructor to enforce singleton pattern + } + + /** + * Get the singleton instance of RuntimeHelper + */ + public static getInstance(): RuntimeHelper { + if (!RuntimeHelper.instance) { + RuntimeHelper.instance = new RuntimeHelper() + } + return RuntimeHelper.instance + } + + /** + * Initialize runtime paths (idempotent operation) + * Caches Node.js and UV runtime paths to avoid repeated filesystem checks + */ + public initializeRuntimes(): void { + if (this.runtimesInitialized) { + return + } + + const runtimeBasePath = path + .join(app.getAppPath(), 'runtime') + .replace('app.asar', 'app.asar.unpacked') + + // Check if node runtime file exists + const nodeRuntimePath = path.join(runtimeBasePath, 'node') + if (process.platform === 'win32') { + const nodeExe = path.join(nodeRuntimePath, 'node.exe') + if (fs.existsSync(nodeExe)) { + this.nodeRuntimePath = nodeRuntimePath + } else { + this.nodeRuntimePath = null + } + } else { + const nodeBin = path.join(nodeRuntimePath, 'bin', 'node') + if (fs.existsSync(nodeBin)) { + this.nodeRuntimePath = nodeRuntimePath + } else { + this.nodeRuntimePath = null + } + } + + // Check if uv runtime file exists + const uvRuntimePath = path.join(runtimeBasePath, 'uv') + if (process.platform === 'win32') { + const uvExe = path.join(uvRuntimePath, 'uv.exe') + const uvxExe = path.join(uvRuntimePath, 'uvx.exe') + if (fs.existsSync(uvExe) && fs.existsSync(uvxExe)) { + this.uvRuntimePath = uvRuntimePath + } else { + this.uvRuntimePath = null + } + } else { + const uvBin = path.join(uvRuntimePath, 'uv') + const uvxBin = path.join(uvRuntimePath, 'uvx') + if (fs.existsSync(uvBin) && fs.existsSync(uvxBin)) { + this.uvRuntimePath = uvRuntimePath + } else { + this.uvRuntimePath = null + } + } + + this.runtimesInitialized = true + } + + /** + * Get Node.js runtime path + * @returns Node.js runtime path or null if not found + */ + public getNodeRuntimePath(): string | null { + return this.nodeRuntimePath + } + + /** + * Get UV runtime path + * @returns UV runtime path or null if not found + */ + public getUvRuntimePath(): string | null { + return this.uvRuntimePath + } + + /** + * Replace command with runtime version if needed + * @param command Original command + * @param useBuiltinRuntime Whether to use builtin runtime + * @param checkExists Whether to check if file exists (default: true) + * @returns Processed command path or original command + */ + public replaceWithRuntimeCommand( + command: string, + useBuiltinRuntime: boolean, + checkExists: boolean = true + ): string { + // If useBuiltinRuntime is false, return original command + if (!useBuiltinRuntime) { + return command + } + + // Get command basename (remove path) + const basename = path.basename(command) + + // Handle Node.js related commands (all platforms use same logic) + if (['node', 'npm', 'npx'].includes(basename)) { + if (this.nodeRuntimePath) { + if (process.platform === 'win32') { + if (basename === 'node') { + const nodeExe = path.join(this.nodeRuntimePath, 'node.exe') + if (checkExists) { + if (fs.existsSync(nodeExe)) { + return nodeExe + } + // If doesn't exist, return original command to let system find it via PATH + return command + } else { + return nodeExe + } + } else if (basename === 'npm') { + // Windows usually has npm as .cmd file + const npmCmd = path.join(this.nodeRuntimePath, 'npm.cmd') + if (checkExists) { + if (fs.existsSync(npmCmd)) { + return npmCmd + } + // Check if npm exists without .cmd extension + const npmPath = path.join(this.nodeRuntimePath, 'npm') + if (fs.existsSync(npmPath)) { + return npmPath + } + // If doesn't exist, return original command to let system find it via PATH + return command + } else { + // For mcpClient: return default path without checking + if (fs.existsSync(npmCmd)) { + return npmCmd + } + return path.join(this.nodeRuntimePath, 'npm') + } + } else if (basename === 'npx') { + // On Windows, npx is typically a .cmd file + const npxCmd = path.join(this.nodeRuntimePath, 'npx.cmd') + if (checkExists) { + if (fs.existsSync(npxCmd)) { + return npxCmd + } + // Check if npx exists without .cmd extension + const npxPath = path.join(this.nodeRuntimePath, 'npx') + if (fs.existsSync(npxPath)) { + return npxPath + } + // If doesn't exist, return original command to let system find it via PATH + return command + } else { + // For mcpClient: return default path without checking + if (fs.existsSync(npxCmd)) { + return npxCmd + } + return path.join(this.nodeRuntimePath, 'npx') + } + } + } else { + // Non-Windows platforms + let targetCommand: string + if (basename === 'node') { + targetCommand = 'node' + } else if (basename === 'npm') { + targetCommand = 'npm' + } else if (basename === 'npx') { + targetCommand = 'npx' + } else { + targetCommand = basename + } + const nodePath = path.join(this.nodeRuntimePath, 'bin', targetCommand) + if (checkExists) { + if (fs.existsSync(nodePath)) { + return nodePath + } + // If doesn't exist, return original command to let system find it via PATH + return command + } else { + return nodePath + } + } + } + } + + // UV command handling (all platforms) + if (['uv', 'uvx'].includes(basename)) { + if (!this.uvRuntimePath) { + return command + } + + // Both uv and uvx use their corresponding commands + const targetCommand = basename === 'uvx' ? 'uvx' : 'uv' + + if (process.platform === 'win32') { + const uvPath = path.join(this.uvRuntimePath, `${targetCommand}.exe`) + if (checkExists) { + if (fs.existsSync(uvPath)) { + return uvPath + } + // If doesn't exist, return original command to let system find it via PATH + return command + } else { + return uvPath + } + } else { + const uvPath = path.join(this.uvRuntimePath, targetCommand) + if (checkExists) { + if (fs.existsSync(uvPath)) { + return uvPath + } + // If doesn't exist, return original command to let system find it via PATH + return command + } else { + return uvPath + } + } + } + + return command + } + + /** + * Process command and arguments with runtime replacement (for mcpClient) + * This method does not check file existence and always tries to replace + * @param command Original command + * @param args Command arguments + * @returns Processed command and arguments + */ + public processCommandWithArgs( + command: string, + args: string[] + ): { command: string; args: string[] } { + return { + command: this.replaceWithRuntimeCommand(command, true, false), + args: args.map((arg) => this.replaceWithRuntimeCommand(arg, true, false)) + } + } + + /** + * Expand various symbols and variables in paths + * @param inputPath Input path that may contain ~ or environment variables + * @returns Expanded path + */ + public expandPath(inputPath: string): string { + let expandedPath = inputPath + + // Handle ~ symbol (user home directory) + if (expandedPath.startsWith('~/') || expandedPath === '~') { + const homeDir = app.getPath('home') + expandedPath = expandedPath.replace('~', homeDir) + } + + // Handle environment variable expansion + expandedPath = expandedPath.replace(/\$\{([^}]+)\}/g, (match, varName) => { + return process.env[varName] || match + }) + + // Handle simple $VAR format (without braces) + expandedPath = expandedPath.replace(/\$([A-Z_][A-Z0-9_]*)/g, (match, varName) => { + return process.env[varName] || match + }) + + return expandedPath + } + + /** + * Normalize PATH environment variable + * @param paths Array of paths to merge + * @returns Normalized PATH key-value pair + */ + public normalizePathEnv(paths: string[]): { key: string; value: string } { + const isWindows = process.platform === 'win32' + const separator = isWindows ? ';' : ':' + const pathKey = isWindows ? 'Path' : 'PATH' + const pathValue = paths.filter(Boolean).join(separator) + return { key: pathKey, value: pathValue } + } + + /** + * Get system-specific default paths + * @param homeDir User home directory + * @returns Array of default system paths + */ + public getDefaultPaths(homeDir: string): string[] { + if (process.platform === 'darwin') { + return [ + '/bin', + '/usr/bin', + '/usr/local/bin', + '/usr/local/sbin', + '/opt/homebrew/bin', + '/opt/homebrew/sbin', + '/usr/local/opt/node/bin', + '/opt/local/bin', + `${homeDir}/.cargo/bin` + ] + } else if (process.platform === 'linux') { + return ['/bin', '/usr/bin', '/usr/local/bin', `${homeDir}/.cargo/bin`] + } else { + // Windows + return [`${homeDir}\\.cargo\\bin`, `${homeDir}\\.local\\bin`] + } + } +} diff --git a/src/main/lib/terminalHelper.ts b/src/main/lib/terminalHelper.ts new file mode 100644 index 000000000..b0cc5402a --- /dev/null +++ b/src/main/lib/terminalHelper.ts @@ -0,0 +1,212 @@ +import { exec } from 'child_process' +import { promisify } from 'util' +import * as fs from 'fs' +import * as path from 'path' +import { tmpdir } from 'os' + +const execAsync = promisify(exec) + +/** + * Helper class for opening user's default terminal and executing commands + */ +export class TerminalHelper { + /** + * Open user's default terminal and execute a command + * @param command The command to execute + * @param keepOpen Whether to keep the terminal open after command execution + */ + static async openTerminalAndExecute(command: string, keepOpen: boolean = true): Promise { + const platform = process.platform + + if (platform === 'darwin') { + await this.openMacOSTerminal(command, keepOpen) + } else if (platform === 'win32') { + await this.openWindowsTerminal(command, keepOpen) + } else { + await this.openLinuxTerminal(command, keepOpen) + } + } + + /** + * Open terminal on macOS + */ + private static async openMacOSTerminal(command: string, keepOpen: boolean): Promise { + try { + // Try to detect default terminal + const defaultTerminal = await this.getMacOSDefaultTerminal() + const scriptContent = this.buildMacOSScript(command, keepOpen) + const scriptPath = await this.createTempScript(scriptContent, '.sh') + + if (defaultTerminal === 'Terminal.app') { + // Use Terminal.app + await execAsync( + `osascript -e 'tell application "Terminal" to do script "bash \\"${scriptPath}\\"'"` + ) + } else if (defaultTerminal === 'iTerm.app') { + // Use iTerm2 + await execAsync( + `osascript -e 'tell application "iTerm" to tell current window to tell current session to write text "bash \\"${scriptPath}\\""'` + ) + } else { + // Fallback to Terminal.app + await execAsync( + `osascript -e 'tell application "Terminal" to do script "bash \\"${scriptPath}\\""'` + ) + } + } catch (error) { + console.error('[TerminalHelper] Failed to open macOS terminal:', error) + throw new Error('Failed to open terminal on macOS') + } + } + + /** + * Get default terminal on macOS + */ + private static async getMacOSDefaultTerminal(): Promise { + try { + const { stdout } = await execAsync( + `defaults read com.apple.terminal "Default Window Settings" 2>/dev/null || echo ""` + ) + if (stdout.trim()) { + return 'Terminal.app' + } + } catch { + // Ignore error + } + + // Check if iTerm2 is installed + try { + await execAsync('test -d /Applications/iTerm.app') + return 'iTerm.app' + } catch { + // iTerm2 not found + } + + return 'Terminal.app' + } + + /** + * Build macOS shell script content + */ + private static buildMacOSScript(command: string, keepOpen: boolean): string { + let script = '#!/bin/bash\n' + script += `cd "${process.cwd()}"\n` + script += `${command}\n` + if (keepOpen) { + script += 'echo ""\n' + script += 'echo "Press Enter to close this window..."\n' + script += 'read\n' + } + return script + } + + /** + * Open terminal on Windows + */ + private static async openWindowsTerminal(command: string, keepOpen: boolean): Promise { + try { + const scriptContent = this.buildWindowsScript(command, keepOpen) + const scriptPath = await this.createTempScript(scriptContent, '.bat') + + // Try Windows Terminal first (modern Windows) + try { + await execAsync(`wt.exe cmd /k "${scriptPath}"`) + return + } catch { + // Windows Terminal not available, fallback to cmd + } + + // Fallback to cmd.exe + await execAsync(`start cmd /k "${scriptPath}"`) + } catch (error) { + console.error('[TerminalHelper] Failed to open Windows terminal:', error) + throw new Error('Failed to open terminal on Windows') + } + } + + /** + * Build Windows batch script content + */ + private static buildWindowsScript(command: string, keepOpen: boolean): string { + let script = `@echo off\n` + script += `cd /d "${process.cwd()}"\n` + script += `${command}\n` + if (keepOpen) { + script += `echo.\n` + script += `echo Press any key to close this window...\n` + script += `pause >nul\n` + } + return script + } + + /** + * Open terminal on Linux + */ + private static async openLinuxTerminal(command: string, keepOpen: boolean): Promise { + try { + const scriptContent = this.buildLinuxScript(command, keepOpen) + const scriptPath = await this.createTempScript(scriptContent, '.sh') + + // Try common terminal emulators + const terminals = [ + 'x-terminal-emulator', + 'gnome-terminal', + 'konsole', + 'xterm', + 'terminator', + 'xfce4-terminal', + 'mate-terminal', + 'tilix' + ] + + for (const terminal of terminals) { + try { + if (terminal === 'x-terminal-emulator') { + await execAsync(`x-terminal-emulator -e bash "${scriptPath}"`) + } else if (terminal === 'gnome-terminal') { + await execAsync(`gnome-terminal -- bash -c "bash '${scriptPath}'; exec bash"`) + } else if (terminal === 'konsole') { + await execAsync(`konsole -e bash "${scriptPath}"`) + } else { + await execAsync(`${terminal} -e bash "${scriptPath}"`) + } + return + } catch { + // Try next terminal + continue + } + } + + throw new Error('No terminal emulator found') + } catch (error) { + console.error('[TerminalHelper] Failed to open Linux terminal:', error) + throw new Error('Failed to open terminal on Linux') + } + } + + /** + * Build Linux shell script content + */ + private static buildLinuxScript(command: string, keepOpen: boolean): string { + let script = '#!/bin/bash\n' + script += `cd "${process.cwd()}"\n` + script += `${command}\n` + if (keepOpen) { + script += 'echo ""\n' + script += 'echo "Press Enter to close this window..."\n' + script += 'read\n' + } + return script + } + + /** + * Create temporary script file + */ + private static async createTempScript(content: string, extension: string): Promise { + const tempDir = tmpdir() + const scriptPath = path.join(tempDir, `deepchat-acp-init-${Date.now()}${extension}`) + + await fs.promises.writeFile(scriptPath, content, { mode: 0o755 }) + return scriptPath + } +} diff --git a/src/main/presenter/configPresenter/acpInitHelper.ts b/src/main/presenter/configPresenter/acpInitHelper.ts new file mode 100644 index 000000000..977a1a399 --- /dev/null +++ b/src/main/presenter/configPresenter/acpInitHelper.ts @@ -0,0 +1,484 @@ +import * as path from 'path' +import { type WebContents } from 'electron' +import type { AcpBuiltinAgentId, AcpAgentConfig, AcpAgentProfile } from '@shared/presenter' +import { spawn } from 'node-pty' +import type { IPty } from 'node-pty' +import { RuntimeHelper } from '@/lib/runtimeHelper' + +interface InitCommandConfig { + commands: string[] + description: string +} + +const BUILTIN_INIT_COMMANDS: Record = { + 'kimi-cli': { + commands: ['uv tool run --from kimi-cli kimi'], + description: 'Initialize Kimi CLI' + }, + 'claude-code-acp': { + commands: [ + 'npm i -g @zed-industries/claude-code-acp', + 'npm install -g @anthropic-ai/claude-code', + 'claude' + ], + description: 'Initialize Claude Code ACP' + }, + 'codex-acp': { + commands: ['npm i -g @zed-industries/codex-acp', 'npm install -g @openai/codex-sdk', 'codex'], + description: 'Initialize Codex CLI ACP' + } +} + +class AcpInitHelper { + private activeShell: IPty | null = null + private readonly runtimeHelper = RuntimeHelper.getInstance() + + constructor() { + this.runtimeHelper.initializeRuntimes() + } + + /** + * Initialize a builtin ACP agent with terminal output streaming + */ + async initializeBuiltinAgent( + agentId: AcpBuiltinAgentId, + profile: AcpAgentProfile, + useBuiltinRuntime: boolean, + npmRegistry: string | null, + uvRegistry: string | null, + webContents?: WebContents + ): Promise { + console.log('[ACP Init] Initializing builtin agent:', { + agentId, + useBuiltinRuntime, + npmRegistry, + uvRegistry, + hasWebContents: !!webContents, + profileName: profile.name + }) + + const initConfig = BUILTIN_INIT_COMMANDS[agentId] + if (!initConfig) { + console.error('[ACP Init] Unknown builtin agent:', agentId) + throw new Error(`Unknown builtin agent: ${agentId}`) + } + + console.log('[ACP Init] Agent config:', { + description: initConfig.description, + commands: initConfig.commands + }) + + const envVars = this.buildEnvironmentVariables( + profile, + useBuiltinRuntime, + npmRegistry, + uvRegistry + ) + + const commands = initConfig.commands + console.log('[ACP Init] Starting interactive session with commands:', commands) + return this.startInteractiveSession(commands, envVars, webContents) + } + + /** + * Initialize a custom ACP agent with terminal output streaming + */ + async initializeCustomAgent( + agent: AcpAgentConfig, + useBuiltinRuntime: boolean, + npmRegistry: string | null, + uvRegistry: string | null, + webContents?: WebContents + ): Promise { + console.log('[ACP Init] Initializing custom agent:', { + name: agent.name, + command: agent.command, + args: agent.args, + useBuiltinRuntime, + npmRegistry, + uvRegistry, + hasWebContents: !!webContents + }) + + const envVars = this.buildEnvironmentVariables( + agent, + useBuiltinRuntime, + npmRegistry, + uvRegistry + ) + + // For custom agents, use the configured command + const command = agent.command + const args = agent.args || [] + const fullCommandStr = [command, ...args].join(' ') + + console.log('[ACP Init] Starting interactive session with custom command:', fullCommandStr) + return this.startInteractiveSession([fullCommandStr], envVars, webContents) + } + + writeToTerminal(data: string) { + if (this.activeShell) { + try { + console.log('[ACP Init] Writing to terminal:', { + dataLength: data.length, + dataPreview: data.substring(0, 50) + }) + this.activeShell.write(data) + } catch (error) { + console.warn('[ACP Init] Cannot write to terminal:', error) + } + } else { + console.warn('[ACP Init] Cannot write to terminal - shell not available') + } + } + + killTerminal() { + if (this.activeShell) { + console.log('[ACP Init] Killing active shell process:', { + pid: this.activeShell.pid + }) + try { + this.activeShell.kill() + } catch (error) { + console.warn('[ACP Init] Error killing shell:', error) + } + this.activeShell = null + console.log('[ACP Init] Shell process killed') + } else { + console.log('[ACP Init] No active shell to kill') + } + } + + /** + * Start an interactive shell session + */ + private startInteractiveSession( + initCommands: string[], + envVars: Record, + webContents?: WebContents + ): IPty | null { + console.log('[ACP Init] Starting interactive session:', { + commands: initCommands, + envVarCount: Object.keys(envVars).length, + hasWebContents: !!webContents + }) + + if (!webContents || webContents.isDestroyed()) { + console.error('[ACP Init] Cannot start session - webContents invalid or destroyed') + return null + } + + // Kill existing shell if any + this.killTerminal() + + const platform = process.platform + let shell: string + let shellArgs: string[] = [] + + if (platform === 'win32') { + shell = 'powershell.exe' + shellArgs = ['-NoLogo'] + } else { + // Use user's default shell or bash/zsh + shell = process.env.SHELL || '/bin/bash' + // Force interactive mode for bash/zsh to get prompt and aliases + if (shell.endsWith('bash') || shell.endsWith('zsh')) { + shellArgs = ['-i'] + } + } + + console.log('[ACP Init] Spawning shell with PTY:', { + platform, + shell, + shellArgs, + cwd: process.cwd() + }) + + // Spawn PTY process + const pty = spawn(shell, shellArgs, { + name: 'xterm-color', + cols: 80, + rows: 24, + cwd: process.cwd(), + env: { ...process.env, ...envVars } as Record + }) + + console.log('[ACP Init] PTY process spawned:', { + pid: pty.pid + }) + + this.activeShell = pty + + // Track shell readiness for command injection + let shellReady = false + let outputBuffer = '' + let commandInjected = false + const maxWaitTime = 3000 // Maximum wait time for shell ready (3 seconds) + const startTime = Date.now() + + // Handle PTY output (PTY combines stdout and stderr into a single stream) + pty.onData((data: string) => { + outputBuffer += data + + console.log('[ACP Init] PTY data:', { + length: data.length, + preview: data.substring(0, 100).replace(/\n/g, '\\n'), + shellReady, + commandInjected + }) + + // Detect shell readiness by looking for prompt patterns or any meaningful output + if (!shellReady && outputBuffer.length > 0) { + // Check for common shell prompt patterns or any non-empty output + const hasPromptPattern = + /[$#>]\s*$/.test(outputBuffer) || outputBuffer.includes('\n') || outputBuffer.length > 10 + + if (hasPromptPattern || Date.now() - startTime > 500) { + shellReady = true + console.log('[ACP Init] Shell detected as ready, output length:', outputBuffer.length) + } + } + + // Send output to renderer (PTY output is treated as stdout) + if (!webContents.isDestroyed()) { + webContents.send('acp-init:output', { type: 'stdout', data }) + } + + // Inject command once shell is ready + if (shellReady && !commandInjected && initCommands.length > 0) { + commandInjected = true + const separator = platform === 'win32' ? ';' : '&&' + const initCmd = initCommands.join(` ${separator} `) + + console.log('[ACP Init] Injecting initialization command (shell ready):', { + command: initCmd, + outputBufferLength: outputBuffer.length + }) + + // Small delay to ensure shell is fully ready + setTimeout(() => { + try { + pty.write(initCmd + '\n') + console.log('[ACP Init] Command written to PTY') + } catch (error) { + console.warn('[ACP Init] Error writing command to PTY:', error) + } + }, 100) + } + }) + + // Handle process exit + pty.onExit(({ exitCode, signal }) => { + console.log('[ACP Init] Process exited:', { + pid: pty.pid, + code: exitCode, + signal, + commandInjected + }) + if (!webContents.isDestroyed()) { + webContents.send('acp-init:exit', { code: exitCode, signal: signal || null }) + } + if (this.activeShell === pty) { + this.activeShell = null + console.log('[ACP Init] Active shell cleared') + } + }) + + // Delay sending start event to ensure renderer listeners are set up + // Also inject command if shell doesn't become ready within timeout + setTimeout(() => { + if (!webContents.isDestroyed()) { + console.log('[ACP Init] Sending start event (delayed to ensure listeners ready)') + webContents.send('acp-init:start', { command: shell }) + } + + // Fallback: inject command if shell hasn't become ready yet + if (!commandInjected && initCommands.length > 0 && Date.now() - startTime < maxWaitTime) { + console.log('[ACP Init] Fallback: injecting command after delay (shell may be ready)') + commandInjected = true + const separator = platform === 'win32' ? ';' : '&&' + const initCmd = initCommands.join(` ${separator} `) + + setTimeout(() => { + try { + pty.write(initCmd + '\n') + console.log('[ACP Init] Fallback command written to PTY') + } catch (error) { + console.warn('[ACP Init] Error writing fallback command to PTY:', error) + } + }, 200) + } + }, 500) // Delay to ensure renderer listeners are set up + + return pty + } + + /** + * Build environment variables for the terminal + */ + private buildEnvironmentVariables( + profile: AcpAgentProfile | AcpAgentConfig, + useBuiltinRuntime: boolean, + npmRegistry: string | null, + uvRegistry: string | null + ): Record { + console.log('[ACP Init] Building environment variables:', { + useBuiltinRuntime, + npmRegistry, + uvRegistry, + hasProfileEnv: !!(profile.env && Object.keys(profile.env).length > 0) + }) + + const env: Record = {} + + // Add system environment variables + const systemEnvCount = Object.entries(process.env).filter( + ([, value]) => value !== undefined && value !== '' + ).length + Object.entries(process.env).forEach(([key, value]) => { + if (value !== undefined && value !== '') { + env[key] = value + } + }) + console.log('[ACP Init] Added system environment variables:', systemEnvCount) + + // Add runtime paths to PATH if using builtin runtime + if (useBuiltinRuntime) { + const pathKey = process.platform === 'win32' ? 'Path' : 'PATH' + const existingPath = env[pathKey] || '' + const separator = process.platform === 'win32' ? ';' : ':' + const runtimePaths: string[] = [] + + const uvRuntimePath = this.runtimeHelper.getUvRuntimePath() + const nodeRuntimePath = this.runtimeHelper.getNodeRuntimePath() + + if (uvRuntimePath) { + runtimePaths.push(uvRuntimePath) + console.log('[ACP Init] Added UV runtime path:', uvRuntimePath) + } + + if (process.platform === 'win32') { + if (nodeRuntimePath) { + runtimePaths.push(nodeRuntimePath) + console.log('[ACP Init] Added Node runtime path (Windows):', nodeRuntimePath) + } + } else { + if (nodeRuntimePath) { + const nodeBinPath = path.join(nodeRuntimePath, 'bin') + runtimePaths.push(nodeBinPath) + console.log('[ACP Init] Added Node runtime path (Unix):', nodeBinPath) + } + } + + if (runtimePaths.length > 0) { + env[pathKey] = [...runtimePaths, existingPath].filter(Boolean).join(separator) + console.log('[ACP Init] Updated PATH with runtime paths:', { + runtimePaths, + finalPathLength: env[pathKey].length + }) + } else { + console.warn('[ACP Init] No runtime paths available to add to PATH') + } + } + + // Add registry environment variables if using builtin runtime + if (useBuiltinRuntime) { + if (npmRegistry && npmRegistry !== '') { + env.npm_config_registry = npmRegistry + env.NPM_CONFIG_REGISTRY = npmRegistry + console.log('[ACP Init] Set NPM registry:', npmRegistry) + } + + if (uvRegistry && uvRegistry !== '') { + env.UV_DEFAULT_INDEX = uvRegistry + env.PIP_INDEX_URL = uvRegistry + console.log('[ACP Init] Set UV registry:', uvRegistry) + } + } + + // Add custom environment variables from profile + if (profile.env) { + const customEnvCount = Object.entries(profile.env).filter( + ([, value]) => value !== undefined && value !== '' + ).length + Object.entries(profile.env).forEach(([key, value]) => { + if (value !== undefined && value !== '') { + if (['PATH', 'Path', 'path'].includes(key)) { + // Merge PATH variables + const pathKey = process.platform === 'win32' ? 'Path' : 'PATH' + const separator = process.platform === 'win32' ? ';' : ':' + const existingPath = env[pathKey] || '' + env[pathKey] = [value, existingPath].filter(Boolean).join(separator) + console.log('[ACP Init] Merged custom PATH from profile:', { + customPath: value, + mergedPathLength: env[pathKey].length + }) + } else { + env[key] = value + console.log('[ACP Init] Added custom env var:', key) + } + } + }) + console.log('[ACP Init] Added custom environment variables from profile:', customEnvCount) + } + + console.log('[ACP Init] Environment variables built:', { + totalEnvVars: Object.keys(env).length, + pathLength: env[process.platform === 'win32' ? 'Path' : 'PATH']?.length || 0 + }) + + return env + } +} + +// Export helper functions +let initHelperInstance: AcpInitHelper | null = null + +function getInitHelper(): AcpInitHelper { + if (!initHelperInstance) { + initHelperInstance = new AcpInitHelper() + } + return initHelperInstance +} + +export async function initializeBuiltinAgent( + agentId: AcpBuiltinAgentId, + profile: AcpAgentProfile, + useBuiltinRuntime: boolean, + npmRegistry: string | null, + uvRegistry: string | null, + webContents?: WebContents +): Promise { + return getInitHelper().initializeBuiltinAgent( + agentId, + profile, + useBuiltinRuntime, + npmRegistry, + uvRegistry, + webContents + ) +} + +export async function initializeCustomAgent( + agent: AcpAgentConfig, + useBuiltinRuntime: boolean, + npmRegistry: string | null, + uvRegistry: string | null, + webContents?: WebContents +): Promise { + return getInitHelper().initializeCustomAgent( + agent, + useBuiltinRuntime, + npmRegistry, + uvRegistry, + webContents + ) +} + +export function writeToTerminal(data: string): void { + getInitHelper().writeToTerminal(data) +} + +export function killTerminal(): void { + getInitHelper().killTerminal() +} diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index 87ea7a72d..69871cb47 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -23,7 +23,7 @@ import { ModelType } from '@shared/model' import ElectronStore from 'electron-store' import { DEFAULT_PROVIDERS } from './providers' import path from 'path' -import { app, nativeTheme, shell } from 'electron' +import { app, nativeTheme, shell, ipcMain } from 'electron' import fs from 'fs' import { CONFIG_EVENTS, SYSTEM_EVENTS, FLOATING_BUTTON_EVENTS } from '@/events' import { McpConfHelper } from './mcpConfHelper' @@ -42,6 +42,13 @@ import { SystemPromptHelper, DEFAULT_SYSTEM_PROMPT } from './systemPromptHelper' import { UiSettingsHelper } from './uiSettingsHelper' import { AcpConfHelper } from './acpConfHelper' import { AcpProvider } from '../llmProviderPresenter/providers/acpProvider' +import { + initializeBuiltinAgent, + initializeCustomAgent, + writeToTerminal, + killTerminal +} from './acpInitHelper' +import { clearShellEnvironmentCache } from '../llmProviderPresenter/agent/shellEnvHelper' // Define application settings interface interface IAppSettings { @@ -181,6 +188,7 @@ export class ConfigPresenter implements IConfigPresenter { this.acpConfHelper = new AcpConfHelper() this.syncAcpProviderEnabled(this.acpConfHelper.getGlobalEnabled()) + this.setupIpcHandlers() // Initialize MCP configuration helper this.mcpConfHelper = new McpConfHelper() @@ -226,6 +234,15 @@ export class ConfigPresenter implements IConfigPresenter { } } + private setupIpcHandlers() { + ipcMain.on('acp-terminal:input', (_event, data: string) => { + writeToTerminal(data) + }) + ipcMain.on('acp-terminal:kill', () => { + killTerminal() + }) + } + private initProviderModelsDir(): void { const modelsDir = path.join(this.userDataPath, PROVIDER_MODELS_DIR) if (!fs.existsSync(modelsDir)) { @@ -953,6 +970,9 @@ export class ConfigPresenter implements IConfigPresenter { async setAcpUseBuiltinRuntime(enabled: boolean): Promise { this.acpConfHelper.setUseBuiltinRuntime(enabled) + // Clear shell environment cache when useBuiltinRuntime changes + // This ensures fresh environment variables are fetched if user switches back to system runtime + clearShellEnvironmentCache() } // ===================== ACP configuration methods ===================== @@ -999,6 +1019,64 @@ export class ConfigPresenter implements IConfigPresenter { return this.acpConfHelper.getCustoms() } + /** + * Initialize an ACP agent with terminal output streaming + */ + async initializeAcpAgent(agentId: string, isBuiltin: boolean): Promise { + const useBuiltinRuntime = await this.getAcpUseBuiltinRuntime() + + // Get npm and uv registry from MCP presenter + let npmRegistry: string | null = null + let uvRegistry: string | null = null + try { + const mcpPresenter = presenter.mcpPresenter + if (mcpPresenter) { + npmRegistry = mcpPresenter.getNpmRegistry?.() ?? null + uvRegistry = mcpPresenter.getUvRegistry?.() ?? null + } + } catch (error) { + console.warn('[ACP Init] Failed to get registry from MCP presenter:', error) + } + + // Get settings window webContents for streaming output + const windowPresenter = presenter.windowPresenter as any + const settingsWindow = windowPresenter?.settingsWindow + const webContents = + settingsWindow && !settingsWindow.isDestroyed() ? settingsWindow.webContents : undefined + + if (isBuiltin) { + // Get builtin agent and its active profile + const builtins = await this.getAcpBuiltinAgents() + const agent = builtins.find((a) => a.id === agentId) + if (!agent) { + throw new Error(`Built-in agent not found: ${agentId}`) + } + + const activeProfile = agent.profiles.find((p) => p.id === agent.activeProfileId) + if (!activeProfile) { + throw new Error(`No active profile found for agent: ${agentId}`) + } + + await initializeBuiltinAgent( + agentId as AcpBuiltinAgentId, + activeProfile, + useBuiltinRuntime, + npmRegistry, + uvRegistry, + webContents + ) + } else { + // Get custom agent + const customs = await this.getAcpCustomAgents() + const agent = customs.find((a) => a.id === agentId) + if (!agent) { + throw new Error(`Custom agent not found: ${agentId}`) + } + + await initializeCustomAgent(agent, useBuiltinRuntime, npmRegistry, uvRegistry, webContents) + } + } + async addAcpBuiltinProfile( agentId: AcpBuiltinAgentId, profile: Omit, diff --git a/src/main/presenter/llmProviderPresenter/agent/acpProcessManager.ts b/src/main/presenter/llmProviderPresenter/agent/acpProcessManager.ts index 94150cd0a..d76bfff11 100644 --- a/src/main/presenter/llmProviderPresenter/agent/acpProcessManager.ts +++ b/src/main/presenter/llmProviderPresenter/agent/acpProcessManager.ts @@ -13,6 +13,8 @@ import type * as schema from '@agentclientprotocol/sdk/dist/schema.js' import type { Stream } from '@agentclientprotocol/sdk/dist/stream.js' import type { AcpAgentConfig } from '@shared/presenter' import type { AgentProcessHandle, AgentProcessManager } from './types' +import { getShellEnvironment } from './shellEnvHelper' +import { RuntimeHelper } from '@/lib/runtimeHelper' export interface AcpProcessHandle extends AgentProcessHandle { child: ChildProcessWithoutNullStreams @@ -61,10 +63,7 @@ export class AcpProcessManager implements AgentProcessManager>() private readonly sessionListeners = new Map() private readonly permissionResolvers = new Map() - private bunRuntimePath: string | null = null - private nodeRuntimePath: string | null = null - private uvRuntimePath: string | null = null - private runtimesInitialized: boolean = false + private readonly runtimeHelper = RuntimeHelper.getInstance() constructor(options: AcpProcessManagerOptions) { this.providerId = options.providerId @@ -212,242 +211,9 @@ export class AcpProcessManager implements AgentProcessManager { - return process.env[varName] || match - }) - - // Handle simple $VAR format (without braces) - expandedPath = expandedPath.replace(/\$([A-Z_][A-Z0-9_]*)/g, (match, varName) => { - return process.env[varName] || match - }) - - return expandedPath - } - private async spawnAgentProcess(agent: AcpAgentConfig): Promise { // Initialize runtime paths if not already done - this.setupRuntimes() + this.runtimeHelper.initializeRuntimes() // Get useBuiltinRuntime configuration const useBuiltinRuntime = await this.getUseBuiltinRuntime() @@ -458,13 +224,17 @@ export class AcpProcessManager implements AgentProcessManager - typeof arg === 'string' ? this.expandPath(arg) : arg + typeof arg === 'string' ? this.runtimeHelper.expandPath(arg) : arg ) // Replace command with runtime version if needed - const processedCommand = this.replaceWithRuntimeCommand(expandedCommand, useBuiltinRuntime) + const processedCommand = this.runtimeHelper.replaceWithRuntimeCommand( + expandedCommand, + useBuiltinRuntime, + true + ) // Validate processed command if (!processedCommand || processedCommand.trim().length === 0) { @@ -489,14 +259,14 @@ export class AcpProcessManager implements AgentProcessManager processedCommand.includes(cmd) || processedArgs.some((arg) => typeof arg === 'string' && arg.includes(cmd)) ) - // Define allowed environment variables whitelist for Node.js/Bun/UV commands + // Define allowed environment variables whitelist for Node.js/UV commands const allowedEnvVars = [ 'PATH', 'path', @@ -522,13 +292,13 @@ export class AcpProcessManager implements AgentProcessManager { - if (value !== undefined) { + if (value !== undefined && value !== '') { if (['PATH', 'Path', 'path'].includes(key)) { existingPaths.push(value) } else if (allowedEnvVars.includes(key) && !['PATH', 'Path', 'path'].includes(key)) { @@ -537,49 +307,80 @@ export class AcpProcessManager implements AgentProcessManager = {} + if (!useBuiltinRuntime) { + try { + shellEnv = await getShellEnvironment() + console.info(`[ACP] Retrieved shell environment variables for agent ${agent.id}`) + + // Merge shell environment variables (except PATH which we handle separately) + Object.entries(shellEnv).forEach(([key, value]) => { + if (!['PATH', 'Path', 'path'].includes(key) && value) { + env[key] = value + } + }) + } catch (error) { + console.warn( + `[ACP] Failed to get shell environment variables for agent ${agent.id}, using fallback:`, + error + ) + } + } + + // Get shell PATH if available (priority: shell PATH > existing PATH) + const shellPath = shellEnv.PATH || shellEnv.Path + if (shellPath) { + // Use shell PATH as base, then merge existing paths + const shellPaths = shellPath.split(process.platform === 'win32' ? ';' : ':') + existingPaths.unshift(...shellPaths) + console.info(`[ACP] Using shell PATH for agent ${agent.id} (length: ${shellPath.length})`) + } + // Get default paths - const defaultPaths = this.getDefaultPaths(HOME_DIR) + const defaultPaths = this.runtimeHelper.getDefaultPaths(HOME_DIR) - // Merge all paths + // Merge all paths (priority: shell PATH > existing PATH > default paths) const allPaths = [...existingPaths, ...defaultPaths] - // Add runtime paths - if (process.platform === 'win32') { - // Windows platform only adds node and uv paths - if (this.uvRuntimePath) { - allPaths.unshift(this.uvRuntimePath) - console.info(`[ACP] Added UV runtime path to PATH: ${this.uvRuntimePath}`) - } - if (this.nodeRuntimePath) { - allPaths.unshift(this.nodeRuntimePath) - console.info(`[ACP] Added Node runtime path to PATH: ${this.nodeRuntimePath}`) - } - } else { - // Other platforms priority: bun > node > uv - if (this.uvRuntimePath) { - allPaths.unshift(this.uvRuntimePath) - console.info(`[ACP] Added UV runtime path to PATH: ${this.uvRuntimePath}`) - } - if (this.nodeRuntimePath) { - const nodeBinPath = path.join(this.nodeRuntimePath, 'bin') - allPaths.unshift(nodeBinPath) - console.info(`[ACP] Added Node bin path to PATH: ${nodeBinPath}`) - } - if (this.bunRuntimePath) { - allPaths.unshift(this.bunRuntimePath) - console.info(`[ACP] Added Bun runtime path to PATH: ${this.bunRuntimePath}`) + // Add runtime paths only when using builtin runtime + if (useBuiltinRuntime) { + const uvRuntimePath = this.runtimeHelper.getUvRuntimePath() + const nodeRuntimePath = this.runtimeHelper.getNodeRuntimePath() + if (process.platform === 'win32') { + // Windows platform only adds node and uv paths + if (uvRuntimePath) { + allPaths.unshift(uvRuntimePath) + console.info(`[ACP] Added UV runtime path to PATH: ${uvRuntimePath}`) + } + if (nodeRuntimePath) { + allPaths.unshift(nodeRuntimePath) + console.info(`[ACP] Added Node runtime path to PATH: ${nodeRuntimePath}`) + } + } else { + // Other platforms priority: node > uv + if (uvRuntimePath) { + allPaths.unshift(uvRuntimePath) + console.info(`[ACP] Added UV runtime path to PATH: ${uvRuntimePath}`) + } + if (nodeRuntimePath) { + const nodeBinPath = path.join(nodeRuntimePath, 'bin') + allPaths.unshift(nodeBinPath) + console.info(`[ACP] Added Node bin path to PATH: ${nodeBinPath}`) + } } } // Normalize and set PATH - const normalized = this.normalizePathEnv(allPaths) + const normalized = this.runtimeHelper.normalizePathEnv(allPaths) pathKey = normalized.key pathValue = normalized.value env[pathKey] = pathValue } } else { - // Non Node.js/Bun/UV commands, preserve all system environment variables, only supplement PATH + // Non Node.js/UV commands, preserve all system environment variables, only supplement PATH Object.entries(process.env).forEach(([key, value]) => { - if (value !== undefined) { + if (value !== undefined && value !== '') { env[key] = value } }) @@ -594,40 +395,40 @@ export class AcpProcessManager implements AgentProcessManager node > uv - if (this.uvRuntimePath) { - allPaths.unshift(this.uvRuntimePath) - console.info(`[ACP] Added UV runtime path to PATH: ${this.uvRuntimePath}`) - } - if (this.nodeRuntimePath) { - const nodeBinPath = path.join(this.nodeRuntimePath, 'bin') - allPaths.unshift(nodeBinPath) - console.info(`[ACP] Added Node bin path to PATH: ${nodeBinPath}`) - } - if (this.bunRuntimePath) { - allPaths.unshift(this.bunRuntimePath) - console.info(`[ACP] Added Bun runtime path to PATH: ${this.bunRuntimePath}`) + // Add runtime paths only when using builtin runtime + if (useBuiltinRuntime) { + const uvRuntimePath = this.runtimeHelper.getUvRuntimePath() + const nodeRuntimePath = this.runtimeHelper.getNodeRuntimePath() + if (process.platform === 'win32') { + // Windows platform only adds node and uv paths + if (uvRuntimePath) { + allPaths.unshift(uvRuntimePath) + console.info(`[ACP] Added UV runtime path to PATH: ${uvRuntimePath}`) + } + if (nodeRuntimePath) { + allPaths.unshift(nodeRuntimePath) + console.info(`[ACP] Added Node runtime path to PATH: ${nodeRuntimePath}`) + } + } else { + // Other platforms priority: node > uv + if (uvRuntimePath) { + allPaths.unshift(uvRuntimePath) + console.info(`[ACP] Added UV runtime path to PATH: ${uvRuntimePath}`) + } + if (nodeRuntimePath) { + const nodeBinPath = path.join(nodeRuntimePath, 'bin') + allPaths.unshift(nodeBinPath) + console.info(`[ACP] Added Node bin path to PATH: ${nodeBinPath}`) + } } } // Normalize and set PATH - const normalized = this.normalizePathEnv(allPaths) + const normalized = this.runtimeHelper.normalizePathEnv(allPaths) pathKey = normalized.key pathValue = normalized.value env[pathKey] = pathValue @@ -636,7 +437,7 @@ export class AcpProcessManager implements AgentProcessManager { - if (value !== undefined) { + if (value !== undefined && value !== '') { // If it's a PATH-related variable, merge into main PATH if (['PATH', 'Path', 'path'].includes(key)) { const currentPathKey = process.platform === 'win32' ? 'Path' : 'PATH' @@ -655,14 +456,14 @@ export class AcpProcessManager implements AgentProcessManager { + // EPIPE errors occur when trying to write to a closed pipe (process already exited) + // This is expected behavior and should be silently handled + if (error.code !== 'EPIPE') { + console.error('[ACP] write error:', error) + } + }) + const writable = Writable.toWeb(child.stdin) as unknown as WritableStream const readable = Readable.toWeb(child.stdout) as unknown as ReadableStream return ndJsonStream(writable, readable) diff --git a/src/main/presenter/llmProviderPresenter/agent/shellEnvHelper.ts b/src/main/presenter/llmProviderPresenter/agent/shellEnvHelper.ts new file mode 100644 index 000000000..dc00ad5c8 --- /dev/null +++ b/src/main/presenter/llmProviderPresenter/agent/shellEnvHelper.ts @@ -0,0 +1,249 @@ +import { spawn } from 'child_process' +import * as path from 'path' + +// Memory cache for shell environment variables +let cachedShellEnv: Record | null = null + +const TIMEOUT_MS = 3000 // 3 seconds timeout + +/** + * Get user's default shell + */ +function getUserShell(): { shell: string; args: string[] } { + const platform = process.platform + + if (platform === 'win32') { + // Windows: use PowerShell or cmd.exe + const powershell = process.env.PSModulePath ? 'powershell.exe' : null + if (powershell) { + return { shell: powershell, args: ['-NoProfile', '-Command'] } + } + return { shell: 'cmd.exe', args: ['/c'] } + } else { + // Unix-like: use SHELL env var or default to bash + const shell = process.env.SHELL || '/bin/bash' + // For interactive shells, use -c to execute command + // For bash/zsh, we need to source profile files to get nvm/etc + if (shell.includes('bash')) { + return { shell, args: ['-c'] } + } else if (shell.includes('zsh')) { + return { shell, args: ['-c'] } + } else if (shell.includes('fish')) { + return { shell, args: ['-c'] } + } else { + return { shell, args: ['-c'] } + } + } +} + +/** + * Execute shell command to get environment variables + * This will source shell initialization files to get nvm/n/fnm/volta paths + */ +async function executeShellEnvCommand(): Promise> { + const { shell, args } = getUserShell() + const platform = process.platform + + // Build command to get environment variables + // For bash/zsh, we need to source common profile files + let envCommand: string + + if (platform === 'win32') { + // Windows: PowerShell command to get all env vars + envCommand = 'Get-ChildItem Env: | ForEach-Object { "$($_.Name)=$($_.Value)" }' + } else { + // Unix-like: source common profile files and then print env + // This ensures nvm/n/fnm/volta initialization scripts are loaded + const shellName = path.basename(shell) + + if (shellName === 'bash') { + // Source .bashrc, .bash_profile, .profile in order + envCommand = ` + [ -f ~/.bashrc ] && source ~/.bashrc + [ -f ~/.bash_profile ] && source ~/.bash_profile + [ -f ~/.profile ] && source ~/.profile + env + `.trim() + } else if (shellName === 'zsh') { + // Source .zshrc + envCommand = ` + [ -f ~/.zshrc ] && source ~/.zshrc + env + `.trim() + } else { + // For other shells, just run env + envCommand = 'env' + } + } + + return new Promise>((resolve, reject) => { + const child = spawn(shell, [...args, envCommand], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env } + }) + + let stdout = '' + let stderr = '' + let timeoutId: NodeJS.Timeout | null = null + + // Set timeout + timeoutId = setTimeout(() => { + child.kill() + reject(new Error(`Shell environment command timed out after ${TIMEOUT_MS}ms`)) + }, TIMEOUT_MS) + + child.stdout?.on('data', (data: Buffer) => { + stdout += data.toString() + }) + + child.stderr?.on('data', (data: Buffer) => { + stderr += data.toString() + }) + + child.on('error', (error) => { + if (timeoutId) clearTimeout(timeoutId) + reject(error) + }) + + child.on('exit', (code, signal) => { + if (timeoutId) clearTimeout(timeoutId) + + if (code !== 0 && signal === null) { + console.warn( + `[ACP] Shell environment command exited with code ${code}, stderr: ${stderr.substring(0, 200)}` + ) + // Don't reject, return empty object as fallback + resolve({}) + return + } + + if (signal) { + console.warn(`[ACP] Shell environment command killed by signal: ${signal}`) + resolve({}) + return + } + + // Parse environment variables from output + const env: Record = {} + + if (platform === 'win32') { + // PowerShell output format: KEY=VALUE + const lines = stdout.split('\n').filter((line) => line.trim().length > 0) + for (const line of lines) { + const match = line.match(/^([^=]+)=(.*)$/) + if (match) { + const [, key, value] = match + env[key.trim()] = value.trim() + } + } + } else { + // Unix env output format: KEY=VALUE + const lines = stdout.split('\n').filter((line) => line.trim().length > 0) + for (const line of lines) { + const match = line.match(/^([^=]+)=(.*)$/) + if (match) { + const [, key, value] = match + env[key.trim()] = value.trim() + } + } + } + + resolve(env) + }) + }) +} + +/** + * Get shell environment variables with caching + * This will source shell initialization files to get nvm/n/fnm/volta paths + */ +export async function getShellEnvironment(): Promise> { + // Check cache first + if (cachedShellEnv !== null) { + console.log('[ACP] Using cached shell environment variables') + return cachedShellEnv + } + + console.log('[ACP] Fetching shell environment variables (this may take a moment)...') + + try { + const shellEnv = await executeShellEnvCommand() + + // Filter and keep only relevant environment variables + // Focus on PATH and Node.js related variables + const filteredEnv: Record = {} + + // Always include PATH (most important for nvm/n/fnm/volta) + if (shellEnv.PATH) { + filteredEnv.PATH = shellEnv.PATH + } + if (shellEnv.Path) { + // Windows uses 'Path' instead of 'PATH' + filteredEnv.Path = shellEnv.Path + } + + // Include Node.js version manager related variables + const nodeEnvVars = [ + 'NVM_DIR', + 'NVM_CD_FLAGS', + 'NVM_BIN', + 'NODE_PATH', + 'NODE_VERSION', + 'FNM_DIR', + 'VOLTA_HOME', + 'N_PREFIX' + ] + + for (const key of nodeEnvVars) { + if (shellEnv[key]) { + filteredEnv[key] = shellEnv[key] + } + } + + // Include npm-related variables + const npmEnvVars = [ + 'npm_config_registry', + 'npm_config_cache', + 'npm_config_prefix', + 'npm_config_tmp', + 'NPM_CONFIG_REGISTRY', + 'NPM_CONFIG_CACHE', + 'NPM_CONFIG_PREFIX', + 'NPM_CONFIG_TMP' + ] + + for (const key of npmEnvVars) { + if (shellEnv[key]) { + filteredEnv[key] = shellEnv[key] + } + } + + // Cache the result + cachedShellEnv = filteredEnv + + console.log('[ACP] Shell environment variables fetched and cached:', { + pathLength: filteredEnv.PATH?.length || filteredEnv.Path?.length || 0, + hasNvm: !!filteredEnv.NVM_DIR, + hasFnm: !!filteredEnv.FNM_DIR, + hasVolta: !!filteredEnv.VOLTA_HOME, + hasN: !!filteredEnv.N_PREFIX, + envVarCount: Object.keys(filteredEnv).length + }) + + return filteredEnv + } catch (error) { + console.warn('[ACP] Failed to get shell environment variables:', error) + // Cache empty object to avoid repeated failures + cachedShellEnv = {} + return {} + } +} + +/** + * Clear the shell environment cache + * Should be called when ACP configuration changes (e.g., useBuiltinRuntime) + */ +export function clearShellEnvironmentCache(): void { + cachedShellEnv = null + console.log('[ACP] Shell environment cache cleared') +} diff --git a/src/main/presenter/mcpPresenter/inMemoryServers/powerpackServer.ts b/src/main/presenter/mcpPresenter/inMemoryServers/powerpackServer.ts index 42e756b98..fa3bfd4a9 100644 --- a/src/main/presenter/mcpPresenter/inMemoryServers/powerpackServer.ts +++ b/src/main/presenter/mcpPresenter/inMemoryServers/powerpackServer.ts @@ -11,6 +11,7 @@ import { execFile } from 'child_process' import { promisify } from 'util' import { nanoid } from 'nanoid' import { Sandbox } from '@e2b/code-interpreter' +import { RuntimeHelper } from '@/lib/runtimeHelper' // Schema 定义 const GetTimeArgsSchema = z.object({ @@ -73,17 +74,16 @@ const CODE_EXECUTION_FORBIDDEN_PATTERNS = [ export class PowerpackServer { private server: Server - private bunRuntimePath: string | null = null - private nodeRuntimePath: string | null = null private useE2B: boolean = false private e2bApiKey: string = '' + private readonly runtimeHelper = RuntimeHelper.getInstance() constructor(env?: Record) { // 从环境变量中获取 E2B 配置 this.parseE2BConfig(env) // 查找内置的运行时路径 - this.setupRuntimes() + this.runtimeHelper.initializeRuntimes() // 创建服务器实例 this.server = new Server( @@ -116,51 +116,6 @@ export class PowerpackServer { } } - // 设置运行时路径 - private setupRuntimes(): void { - const runtimeBasePath = path - .join(app.getAppPath(), 'runtime') - .replace('app.asar', 'app.asar.unpacked') - - // 设置 Bun 运行时路径 - const bunRuntimePath = path.join(runtimeBasePath, 'bun') - if (process.platform === 'win32') { - const bunExe = path.join(bunRuntimePath, 'bun.exe') - if (fs.existsSync(bunExe)) { - this.bunRuntimePath = bunRuntimePath - } - } else { - const bunBin = path.join(bunRuntimePath, 'bun') - if (fs.existsSync(bunBin)) { - this.bunRuntimePath = bunRuntimePath - } - } - - // 设置 Node.js 运行时路径 - const nodeRuntimePath = path.join(runtimeBasePath, 'node') - if (process.platform === 'win32') { - const nodeExe = path.join(nodeRuntimePath, 'node.exe') - if (fs.existsSync(nodeExe)) { - this.nodeRuntimePath = nodeRuntimePath - } - } else { - const nodeBin = path.join(nodeRuntimePath, 'bin', 'node') - if (fs.existsSync(nodeBin)) { - this.nodeRuntimePath = nodeRuntimePath - } - } - - if (!this.bunRuntimePath && !this.nodeRuntimePath && !this.useE2B) { - console.warn('No runtime found (Bun, Node.js, or E2B), code execution will be unavailable') - } else if (this.useE2B) { - console.info('Using E2B for code execution') - } else if (this.bunRuntimePath) { - console.info('Using built-in Bun runtime') - } else if (this.nodeRuntimePath) { - console.info('Using built-in Node.js runtime') - } - } - // 启动服务器 public async startServer(transport: Transport): Promise { this.server.connect(transport) @@ -183,13 +138,9 @@ export class PowerpackServer { // 执行JavaScript代码 private async executeJavaScriptCode(code: string, timeout: number): Promise { - // Windows平台只检查Node.js,其他平台检查Bun和Node.js - const hasRuntime = - process.platform === 'win32' - ? this.nodeRuntimePath - : this.bunRuntimePath || this.nodeRuntimePath - - if (!hasRuntime) { + // 所有平台都使用 Node.js + const nodeRuntimePath = this.runtimeHelper.getNodeRuntimePath() + if (!nodeRuntimePath) { throw new Error('运行时未找到,无法执行代码') } @@ -209,20 +160,13 @@ export class PowerpackServer { let executable: string let args: string[] - // Windows平台使用Node.js,其他平台优先使用Bun + // 所有平台都使用 Node.js if (process.platform === 'win32') { - // Windows只使用Node.js - executable = path.join(this.nodeRuntimePath!, 'node.exe') + executable = path.join(nodeRuntimePath, 'node.exe') args = [tempFile] } else { - // 其他平台优先使用Bun - if (this.bunRuntimePath) { - executable = path.join(this.bunRuntimePath, 'bun') - args = [tempFile] - } else { - executable = path.join(this.nodeRuntimePath!, 'bin', 'node') - args = [tempFile] - } + executable = path.join(nodeRuntimePath, 'bin', 'node') + args = [tempFile] } // 执行代码并添加超时控制 @@ -352,16 +296,10 @@ export class PowerpackServer { }) } else { // 使用本地运行时执行代码 - const hasLocalRuntime = - process.platform === 'win32' - ? this.nodeRuntimePath - : this.bunRuntimePath || this.nodeRuntimePath - - if (hasLocalRuntime) { + const nodeRuntimePath = this.runtimeHelper.getNodeRuntimePath() + if (nodeRuntimePath) { const runtimeDescription = - process.platform === 'win32' - ? 'Execute simple JavaScript/TypeScript code in a secure sandbox environment using Node.js runtime on Windows platform. ' - : 'Execute simple JavaScript/TypeScript code in a secure sandbox environment (Bun or Node.js). Non-Windows platforms prioritize Bun runtime. ' + 'Execute simple JavaScript/TypeScript code in a secure sandbox environment using Node.js runtime. ' tools.push({ name: 'run_node_code', @@ -475,7 +413,8 @@ export class PowerpackServer { throw new Error('Local code execution is disabled when E2B is enabled') } - if (!this.bunRuntimePath && !this.nodeRuntimePath) { + const nodeRuntimePath = this.runtimeHelper.getNodeRuntimePath() + if (!nodeRuntimePath) { throw new Error('JavaScript runtime is not available, cannot execute code') } diff --git a/src/main/presenter/mcpPresenter/mcpClient.ts b/src/main/presenter/mcpPresenter/mcpClient.ts index 1183a44dd..7d2b11869 100644 --- a/src/main/presenter/mcpPresenter/mcpClient.ts +++ b/src/main/presenter/mcpPresenter/mcpClient.ts @@ -19,10 +19,10 @@ import { MCP_EVENTS } from '@/events' import path from 'path' import { presenter } from '@/presenter' import { app } from 'electron' -import fs from 'fs' // import { NO_PROXY, proxyConfig } from '@/presenter/proxyConfig' import { getInMemoryServer } from './inMemoryServers/builder' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { RuntimeHelper } from '@/lib/runtimeHelper' import { PromptListEntry, ToolCallResult, @@ -119,11 +119,9 @@ export class McpClient { public serverConfig: Record private isConnected: boolean = false private connectionTimeout: NodeJS.Timeout | null = null - private bunRuntimePath: string | null = null - private nodeRuntimePath: string | null = null - private uvRuntimePath: string | null = null private npmRegistry: string | null = null private uvRegistry: string | null = null + private readonly runtimeHelper = RuntimeHelper.getInstance() // Session management private isRecovering: boolean = false @@ -134,176 +132,6 @@ export class McpClient { private cachedPrompts: PromptListEntry[] | null = null private cachedResources: ResourceListEntry[] | null = null - // Function to handle PATH environment variables - private normalizePathEnv(paths: string[]): { key: string; value: string } { - const isWindows = process.platform === 'win32' - const separator = isWindows ? ';' : ':' - const pathKey = isWindows ? 'Path' : 'PATH' - - // Merge all paths - const pathValue = paths.filter(Boolean).join(separator) - - return { key: pathKey, value: pathValue } - } - - // Expand various symbols and variables in paths - private expandPath(inputPath: string): string { - let expandedPath = inputPath - - // Handle ~ symbol (user home directory) - if (expandedPath.startsWith('~/') || expandedPath === '~') { - const homeDir = app.getPath('home') - expandedPath = expandedPath.replace('~', homeDir) - } - - // Handle environment variable expansion - expandedPath = expandedPath.replace(/\$\{([^}]+)\}/g, (match, varName) => { - return process.env[varName] || match - }) - - // Handle simple $VAR format (without braces) - expandedPath = expandedPath.replace(/\$([A-Z_][A-Z0-9_]*)/g, (match, varName) => { - return process.env[varName] || match - }) - - return expandedPath - } - - // Replace command with runtime version - private replaceWithRuntimeCommand(command: string): string { - // Get command basename (remove path) - const basename = path.basename(command) - - // Choose corresponding runtime path based on command type - if (process.platform === 'win32') { - // Windows platform only replaces Node.js related commands, let system handle bun commands automatically - if (this.nodeRuntimePath) { - if (basename === 'node') { - return path.join(this.nodeRuntimePath, 'node.exe') - } else if (basename === 'npm') { - // Windows usually has npm as .cmd file - const npmCmd = path.join(this.nodeRuntimePath, 'npm.cmd') - if (fs.existsSync(npmCmd)) { - return npmCmd - } - // If doesn't exist, return default path - return path.join(this.nodeRuntimePath, 'npm') - } else if (basename === 'npx') { - // On Windows, npx is typically a .cmd file - const npxCmd = path.join(this.nodeRuntimePath, 'npx.cmd') - if (fs.existsSync(npxCmd)) { - return npxCmd - } - // If doesn't exist, return default path - return path.join(this.nodeRuntimePath, 'npx') - } - } - } else { - // Non-Windows platforms handle all commands - if (['node', 'npm', 'npx', 'bun'].includes(basename)) { - // Prefer Bun if available, otherwise use Node.js - if (this.bunRuntimePath) { - // For node/npm/npx, uniformly replace with bun - const targetCommand = 'bun' - return path.join(this.bunRuntimePath, targetCommand) - } else if (this.nodeRuntimePath) { - // Use Node.js runtime - let targetCommand: string - if (basename === 'node') { - targetCommand = 'node' - } else if (basename === 'npm') { - targetCommand = 'npm' - } else if (basename === 'npx') { - targetCommand = 'npx' - } else if (basename === 'bun') { - targetCommand = 'node' // Map bun command to node - } else { - targetCommand = basename - } - return path.join(this.nodeRuntimePath, 'bin', targetCommand) - } - } - } - - // UV command handling (all platforms) - if (['uv', 'uvx'].includes(basename)) { - if (!this.uvRuntimePath) { - return command - } - - // Both uv and uvx use their corresponding commands - const targetCommand = basename === 'uvx' ? 'uvx' : 'uv' - - if (process.platform === 'win32') { - return path.join(this.uvRuntimePath, `${targetCommand}.exe`) - } else { - return path.join(this.uvRuntimePath, targetCommand) - } - } - - return command - } - - // Handle special parameter replacement (e.g., npx -> bun x) - private processCommandWithArgs( - command: string, - args: string[] - ): { command: string; args: string[] } { - const basename = path.basename(command) - - // Handle npx command - if (basename === 'npx' || command.includes('npx')) { - if (process.platform === 'win32') { - // Windows platform uses Node.js npx, keep original arguments - return { - command: this.replaceWithRuntimeCommand(command), - args: args.map((arg) => this.replaceWithRuntimeCommand(arg)) - } - } else { - // Non-Windows platforms prefer Bun, need to add 'x' before arguments - if (this.bunRuntimePath) { - return { - command: this.replaceWithRuntimeCommand(command), - args: ['x', ...args] - } - } else if (this.nodeRuntimePath) { - // If no Bun available, use Node.js with original arguments - return { - command: this.replaceWithRuntimeCommand(command), - args: args.map((arg) => this.replaceWithRuntimeCommand(arg)) - } - } - } - } - - return { - command: this.replaceWithRuntimeCommand(command), - args: args.map((arg) => this.replaceWithRuntimeCommand(arg)) - } - } - - // Get system-specific default paths - private getDefaultPaths(homeDir: string): string[] { - if (process.platform === 'darwin') { - return [ - '/bin', - '/usr/bin', - '/usr/local/bin', - '/usr/local/sbin', - '/opt/homebrew/bin', - '/opt/homebrew/sbin', - '/usr/local/opt/node/bin', - '/opt/local/bin', - `${homeDir}/.cargo/bin` - ] - } else if (process.platform === 'linux') { - return ['/bin', '/usr/bin', '/usr/local/bin', `${homeDir}/.cargo/bin`] - } else { - // Windows - return [`${homeDir}\\.cargo\\bin`, `${homeDir}\\.local\\bin`] - } - } - constructor( serverName: string, serverConfig: Record, @@ -314,67 +142,6 @@ export class McpClient { this.serverConfig = serverConfig this.npmRegistry = npmRegistry this.uvRegistry = uvRegistry - - const runtimeBasePath = path - .join(app.getAppPath(), 'runtime') - .replace('app.asar', 'app.asar.unpacked') - console.info('runtimeBasePath', runtimeBasePath) - - // Check if bun runtime file exists - const bunRuntimePath = path.join(runtimeBasePath, 'bun') - if (process.platform === 'win32') { - const bunExe = path.join(bunRuntimePath, 'bun.exe') - if (fs.existsSync(bunExe)) { - this.bunRuntimePath = bunRuntimePath - } else { - this.bunRuntimePath = null - } - } else { - const bunBin = path.join(bunRuntimePath, 'bun') - if (fs.existsSync(bunBin)) { - this.bunRuntimePath = bunRuntimePath - } else { - this.bunRuntimePath = null - } - } - - // Check if node runtime file exists - const nodeRuntimePath = path.join(runtimeBasePath, 'node') - if (process.platform === 'win32') { - const nodeExe = path.join(nodeRuntimePath, 'node.exe') - if (fs.existsSync(nodeExe)) { - this.nodeRuntimePath = nodeRuntimePath - } else { - this.nodeRuntimePath = null - } - } else { - const nodeBin = path.join(nodeRuntimePath, 'bin', 'node') - if (fs.existsSync(nodeBin)) { - this.nodeRuntimePath = nodeRuntimePath - } else { - this.nodeRuntimePath = null - } - } - - // Check if uv runtime file exists - const uvRuntimePath = path.join(runtimeBasePath, 'uv') - if (process.platform === 'win32') { - const uvExe = path.join(uvRuntimePath, 'uv.exe') - const uvxExe = path.join(uvRuntimePath, 'uvx.exe') - if (fs.existsSync(uvExe) && fs.existsSync(uvxExe)) { - this.uvRuntimePath = uvRuntimePath - } else { - this.uvRuntimePath = null - } - } else { - const uvBin = path.join(uvRuntimePath, 'uv') - const uvxBin = path.join(uvRuntimePath, 'uvx') - if (fs.existsSync(uvBin) && fs.existsSync(uvxBin)) { - this.uvRuntimePath = uvRuntimePath - } else { - this.uvRuntimePath = null - } - } } // Connect to MCP server @@ -406,13 +173,16 @@ export class McpClient { _server.startServer(serverTransport) this.transport = clientTransport } else if (this.serverConfig.type === 'stdio') { + // Initialize runtime paths if not already done + this.runtimeHelper.initializeRuntimes() + // Create appropriate transport let command = this.serverConfig.command as string let args = this.serverConfig.args as string[] // Handle path expansion (including ~ and environment variables) - command = this.expandPath(command) - args = args.map((arg) => this.expandPath(arg)) + command = this.runtimeHelper.expandPath(command) + args = args.map((arg) => this.runtimeHelper.expandPath(arg)) const HOME_DIR = app.getPath('home') @@ -437,17 +207,17 @@ export class McpClient { const env: Record = {} // Handle command and argument replacement - const processedCommand = this.processCommandWithArgs(command, args) + const processedCommand = this.runtimeHelper.processCommandWithArgs(command, args) command = processedCommand.command args = processedCommand.args - // Determine if it's Node.js/Bun/UV related command - const isNodeCommand = ['node', 'npm', 'npx', 'bun', 'uv', 'uvx'].some( + // Determine if it's Node.js/UV related command + const isNodeCommand = ['node', 'npm', 'npx', 'uv', 'uvx'].some( (cmd) => command.includes(cmd) || args.some((arg) => arg.includes(cmd)) ) if (isNodeCommand) { - // Node.js/Bun/UV commands use whitelist processing + // Node.js/UV commands use whitelist processing if (process.env) { const existingPaths: string[] = [] @@ -466,38 +236,37 @@ export class McpClient { }) // Get default paths - const defaultPaths = this.getDefaultPaths(HOME_DIR) + const defaultPaths = this.runtimeHelper.getDefaultPaths(HOME_DIR) // 合并所有路径 const allPaths = [...existingPaths, ...defaultPaths] // 添加运行时路径 + const uvRuntimePath = this.runtimeHelper.getUvRuntimePath() + const nodeRuntimePath = this.runtimeHelper.getNodeRuntimePath() if (process.platform === 'win32') { // Windows平台只添加 node 和 uv 路径 - if (this.uvRuntimePath) { - allPaths.unshift(this.uvRuntimePath) + if (uvRuntimePath) { + allPaths.unshift(uvRuntimePath) } - if (this.nodeRuntimePath) { - allPaths.unshift(this.nodeRuntimePath) + if (nodeRuntimePath) { + allPaths.unshift(nodeRuntimePath) } } else { - // 其他平台优先级:bun > node > uv - if (this.uvRuntimePath) { - allPaths.unshift(this.uvRuntimePath) + // 其他平台优先级:node > uv + if (uvRuntimePath) { + allPaths.unshift(uvRuntimePath) } - if (this.nodeRuntimePath) { - allPaths.unshift(path.join(this.nodeRuntimePath, 'bin')) - } - if (this.bunRuntimePath) { - allPaths.unshift(this.bunRuntimePath) + if (nodeRuntimePath) { + allPaths.unshift(path.join(nodeRuntimePath, 'bin')) } } // 规范化并设置PATH - const { key, value } = this.normalizePathEnv(allPaths) + const { key, value } = this.runtimeHelper.normalizePathEnv(allPaths) env[key] = value } } else { - // 非 Node.js/Bun/UV 命令,保留所有系统环境变量,只补充 PATH + // 非 Node.js/UV 命令,保留所有系统环境变量,只补充 PATH Object.entries(process.env).forEach(([key, value]) => { if (value !== undefined) { env[key] = value @@ -514,34 +283,33 @@ export class McpClient { } // 获取默认路径 - const defaultPaths = this.getDefaultPaths(HOME_DIR) + const defaultPaths = this.runtimeHelper.getDefaultPaths(HOME_DIR) // 合并所有路径 const allPaths = [...existingPaths, ...defaultPaths] // 添加运行时路径 + const uvRuntimePath = this.runtimeHelper.getUvRuntimePath() + const nodeRuntimePath = this.runtimeHelper.getNodeRuntimePath() if (process.platform === 'win32') { // Windows平台只添加 node 和 uv 路径 - if (this.uvRuntimePath) { - allPaths.unshift(this.uvRuntimePath) + if (uvRuntimePath) { + allPaths.unshift(uvRuntimePath) } - if (this.nodeRuntimePath) { - allPaths.unshift(this.nodeRuntimePath) + if (nodeRuntimePath) { + allPaths.unshift(nodeRuntimePath) } } else { - // 其他平台优先级:bun > node > uv - if (this.uvRuntimePath) { - allPaths.unshift(this.uvRuntimePath) - } - if (this.nodeRuntimePath) { - allPaths.unshift(path.join(this.nodeRuntimePath, 'bin')) + // 其他平台优先级:node > uv + if (uvRuntimePath) { + allPaths.unshift(uvRuntimePath) } - if (this.bunRuntimePath) { - allPaths.unshift(this.bunRuntimePath) + if (nodeRuntimePath) { + allPaths.unshift(path.join(nodeRuntimePath, 'bin')) } } // 规范化并设置PATH - const { key, value } = this.normalizePathEnv(allPaths) + const { key, value } = this.runtimeHelper.normalizePathEnv(allPaths) env[key] = value } diff --git a/src/renderer/settings/components/AcpSettings.vue b/src/renderer/settings/components/AcpSettings.vue index 067b6aa92..2c3527333 100644 --- a/src/renderer/settings/components/AcpSettings.vue +++ b/src/renderer/settings/components/AcpSettings.vue @@ -125,6 +125,18 @@ + @@ -200,6 +212,18 @@ + @@ -232,6 +256,11 @@ @update:open="(value) => (profileDialogState.open = value)" @save="handleProfileSave" /> + + @@ -266,6 +295,7 @@ import { } from '@shadcn/components/ui/select' import AcpProfileDialog from './AcpProfileDialog.vue' import AcpProfileManagerDialog from './AcpProfileManagerDialog.vue' +import AcpTerminalDialog from './AcpTerminalDialog.vue' const { t } = useI18n() const { toast } = useToast() @@ -284,6 +314,8 @@ const savingProfile = ref(false) const builtinPending = reactive>({}) const customPending = reactive>({}) +const initializing = reactive>({}) +const terminalDialogOpen = ref(false) const profileDialogState = reactive({ open: false, @@ -633,6 +665,49 @@ const deleteCustomAgent = async (agent: AcpCustomAgent) => { } } +const isInitializing = (agentId: string, isBuiltin: boolean): boolean => { + const key = `${isBuiltin ? 'builtin' : 'custom'}-${agentId}` + return Boolean(initializing[key]) +} + +const setInitializing = (agentId: string, isBuiltin: boolean, state: boolean) => { + const key = `${isBuiltin ? 'builtin' : 'custom'}-${agentId}` + if (state) { + initializing[key] = true + } else { + delete initializing[key] + } +} + +const handleInitializeAgent = async (agentId: string, isBuiltin: boolean) => { + if (isInitializing(agentId, isBuiltin)) return + + setInitializing(agentId, isBuiltin, true) + try { + // Open terminal dialog first + console.log('[AcpSettings] Opening terminal dialog for agent initialization') + terminalDialogOpen.value = true + + // Wait for dialog to open, terminal to initialize, and IPC listeners to be set up + // The terminal initialization takes ~150ms + fitting attempts + // The main process delays start event by 500ms to ensure listeners are ready + // So we wait a bit longer to ensure everything is ready + await new Promise((resolve) => setTimeout(resolve, 600)) + + console.log('[AcpSettings] Starting agent initialization') + // Start initialization (output will be streamed to terminal) + await configPresenter.initializeAcpAgent(agentId, isBuiltin) + + // Note: Success/error will be shown in terminal, not via toast + } catch (error) { + console.error('[AcpSettings] Agent initialization failed:', error) + handleError(error, 'settings.acp.initializeFailed') + terminalDialogOpen.value = false + } finally { + setInitializing(agentId, isBuiltin, false) + } +} + watch( () => profileDialogState.open, (open) => { diff --git a/src/renderer/settings/components/AcpTerminalDialog.vue b/src/renderer/settings/components/AcpTerminalDialog.vue new file mode 100644 index 000000000..a8e87e27d --- /dev/null +++ b/src/renderer/settings/components/AcpTerminalDialog.vue @@ -0,0 +1,386 @@ + + + + + diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index df33d4650..a05ddbf18 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -824,6 +824,11 @@ "saveSuccess": "Configuration saved", "saveFailed": "Failed to save agent", "deleteSuccess": "Deleted successfully", + "initialize": "Initialize", + "initializing": "Initializing...", + "initializeDescription": "Terminal opened with initialization commands", + "initializeSuccess": "Initialization started", + "initializeFailed": "Initialization failed", "missingFieldsTitle": "Name and command are required", "missingFieldsDesc": "Please fill in both name and command before saving.", "command": "Command", @@ -857,6 +862,23 @@ "cannotDeleteTitle": "Keep at least one profile", "cannotDeleteDesc": "Built-in agents require at least one configuration.", "noAgent": "Select an agent to manage its profiles." + }, + "terminal": { + "title": "Initialization Terminal", + "description": "Watch the initialization process in real-time.", + "waiting": "Waiting for initialization to start...", + "starting": "Starting initialization...", + "close": "Close", + "closing": "Closing...", + "exitSuccess": "Process completed successfully (exit code: {code})", + "exitError": "Process exited with error (exit code: {code})", + "processError": "Process error", + "status": { + "idle": "Idle", + "running": "Running", + "completed": "Completed", + "error": "Error" + } } }, "rateLimit": { diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index 07b612cde..0f224cbb8 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -883,6 +883,28 @@ "title": "لیست پیکربندی" }, "profilePlaceholder": "لطفا پیکربندی را انتخاب کنید", - "profileSwitched": "پیکربندی تغییر کرد" + "profileSwitched": "پیکربندی تغییر کرد", + "initialize": "مقداردهی اولیه", + "initializeDescription": "در ترمینال باز شد و دستور مقداردهی اولیه اجرا شد", + "initializeFailed": "اولیه سازی انجام نشد", + "initializeSuccess": "راه اندازی آغاز شد", + "initializing": "در حال شروع...", + "terminal": { + "close": "بسته شدن", + "closing": "بسته شدن...", + "description": "روند اولیه سازی را در زمان واقعی مشاهده کنید.", + "exitError": "هنگام خروج از فرآیند خطایی روی داد (کد خروج: {code})", + "exitSuccess": "فرآیند با موفقیت انجام شد (کد خروج: {code})", + "processError": "خطای فرآیند", + "starting": "شروع اولیه سازی...", + "status": { + "completed": "تکمیل شد", + "error": "اشتباه", + "idle": "بیکار", + "running": "در حال دویدن" + }, + "title": "ترمینال را راه اندازی کنید", + "waiting": "در انتظار شروع اولیه سازی..." + } } } diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index 9dc813daf..2e8035ec8 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -883,6 +883,28 @@ "profilePlaceholder": "Veuillez sélectionner la configuration", "profileSwitched": "Configuration commutée", "disabledBadge": "Non activé", - "manageProfiles": "Gestion de la configuration" + "manageProfiles": "Gestion de la configuration", + "initialize": "initialisation", + "initializeDescription": "Ouvert dans le terminal et commande d'initialisation exécutée", + "initializeFailed": "L'initialisation a échoué", + "initializeSuccess": "L'initialisation a commencé", + "initializing": "Initialisation...", + "terminal": { + "close": "fermeture", + "closing": "Clôture...", + "description": "Visualisez le processus d'initialisation en temps réel.", + "exitError": "Une erreur s'est produite lors de la fermeture du processus (code de sortie : {code})", + "exitSuccess": "Processus terminé avec succès (code de sortie : {code})", + "processError": "erreur de processus", + "starting": "Démarrage de l'initialisation...", + "status": { + "completed": "Complété", + "error": "erreur", + "idle": "inactif", + "running": "En cours d'exécution" + }, + "title": "Initialiser le terminal", + "waiting": "En attente du début de l'initialisation..." + } } } diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index 92e9cf494..10dc90f20 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -883,6 +883,28 @@ "setActive": "アクティブに設定する" }, "profilePlaceholder": "設定を選択してください", - "profileSwitched": "設定が切り替えられました" + "profileSwitched": "設定が切り替えられました", + "initialize": "初期化", + "initializeDescription": "ターミナルで開いて初期化コマンドを実行", + "initializeFailed": "初期化に失敗しました", + "initializeSuccess": "初期化を開始しました", + "initializing": "初期化中...", + "terminal": { + "close": "閉鎖", + "closing": "閉会中...", + "description": "初期化プロセスをリアルタイムで表示します。", + "exitError": "プロセスの終了中にエラーが発生しました (終了コード: {code})", + "exitSuccess": "プロセスは正常に完了しました (終了コード: {code})", + "processError": "プロセスエラー", + "starting": "初期化を開始しています...", + "status": { + "completed": "完了しました", + "error": "間違い", + "idle": "アイドル状態", + "running": "ランニング" + }, + "title": "端末の初期化", + "waiting": "初期化が始まるのを待っています..." + } } } diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index 6b395d51c..a7f177f7d 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -883,6 +883,28 @@ "title": "구성 목록" }, "profilePlaceholder": "구성을 선택하세요", - "profileSwitched": "구성이 전환되었습니다" + "profileSwitched": "구성이 전환되었습니다", + "initialize": "초기화", + "initializeDescription": "터미널에서 열고 초기화 명령을 실행했습니다.", + "initializeFailed": "초기화 실패", + "initializeSuccess": "초기화가 시작되었습니다", + "initializing": "초기화 중...", + "terminal": { + "close": "폐쇄", + "closing": "폐쇄...", + "description": "초기화 과정을 실시간으로 확인하세요.", + "exitError": "프로세스가 종료되는 동안 오류가 발생했습니다(종료 코드: {code})", + "exitSuccess": "프로세스가 성공적으로 완료되었습니다(종료 코드: {code})", + "processError": "프로세스 오류", + "starting": "초기화 시작 중...", + "status": { + "completed": "완전한", + "error": "실수", + "idle": "게으른", + "running": "달리기" + }, + "title": "터미널 초기화", + "waiting": "초기화가 시작되기를 기다리는 중..." + } } } diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index bddbf6d88..93bba7b9e 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -883,6 +883,28 @@ "title": "Lista de Configurações" }, "profilePlaceholder": "Por favor, selecione a configuração", - "profileSwitched": "Configuração alterada" + "profileSwitched": "Configuração alterada", + "initialize": "inicialização", + "initializeDescription": "Aberto no terminal e comando de inicialização executado", + "initializeFailed": "Falha na inicialização", + "initializeSuccess": "Inicialização iniciada", + "initializing": "Inicializando...", + "terminal": { + "close": "encerramento", + "closing": "Fechando...", + "description": "Veja o processo de inicialização em tempo real.", + "exitError": "Ocorreu um erro durante a saída do processo (código de saída: {code})", + "exitSuccess": "Processo concluído com sucesso (código de saída: {code})", + "processError": "erro de processo", + "starting": "Iniciando a inicialização...", + "status": { + "completed": "Concluído", + "error": "erro", + "idle": "parado", + "running": "Correndo" + }, + "title": "Inicializar terminal", + "waiting": "Aguardando o início da inicialização..." + } } } diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index 2b6ab31a3..2bafa767c 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -883,6 +883,28 @@ "title": "список конфигураций" }, "profilePlaceholder": "Пожалуйста, выберите конфигурацию", - "profileSwitched": "Конфигурация переключена" + "profileSwitched": "Конфигурация переключена", + "initialize": "инициализация", + "initializeDescription": "Открыт в терминале и выполнил команду инициализации.", + "initializeFailed": "Инициализация не удалась", + "initializeSuccess": "Инициализация началась", + "initializing": "Инициализация...", + "terminal": { + "close": "закрытие", + "closing": "Закрытие...", + "description": "Просмотр процесса инициализации в режиме реального времени.", + "exitError": "Произошла ошибка при выходе из процесса (код выхода: {code}).", + "exitSuccess": "Процесс успешно завершен (код выхода: {code})", + "processError": "ошибка процесса", + "starting": "Начинаем инициализацию...", + "status": { + "completed": "Завершенный", + "error": "ошибка", + "idle": "праздный", + "running": "Бег" + }, + "title": "Инициализировать терминал", + "waiting": "Ожидание начала инициализации..." + } } } diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index a5c1be4d8..22d38a996 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -824,6 +824,11 @@ "saveSuccess": "已保存配置", "saveFailed": "保存失败", "deleteSuccess": "删除成功", + "initialize": "初始化", + "initializing": "正在初始化...", + "initializeDescription": "已在终端中打开并执行初始化命令", + "initializeSuccess": "初始化已启动", + "initializeFailed": "初始化失败", "missingFieldsTitle": "名称和命令必填", "missingFieldsDesc": "请先填写名称和命令。", "command": "命令", @@ -857,6 +862,23 @@ "cannotDeleteTitle": "至少保留一个配置", "cannotDeleteDesc": "内置 Agent 至少需要一套配置。", "noAgent": "请选择要管理的 Agent。" + }, + "terminal": { + "title": "初始化终端", + "description": "实时查看初始化过程。", + "waiting": "等待初始化开始...", + "starting": "正在启动初始化...", + "close": "关闭", + "closing": "正在关闭...", + "exitSuccess": "进程成功完成(退出码:{code})", + "exitError": "进程退出时出错(退出码:{code})", + "processError": "进程错误", + "status": { + "idle": "空闲", + "running": "运行中", + "completed": "已完成", + "error": "错误" + } } }, "rateLimit": { diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index 24b49ab28..5cd5ef2fd 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -883,6 +883,28 @@ "cannotDeleteTitle": "至少保留一組設定", "cannotDeleteDesc": "內建 Agent 最少要有一組設定。", "noAgent": "請揀一個要管理嘅 Agent。" + }, + "initialize": "初始化", + "initializeDescription": "已在終端中打開並執行初始化命令", + "initializeFailed": "初始化失敗", + "initializeSuccess": "初始化已啟動", + "initializing": "正在初始化...", + "terminal": { + "close": "關閉", + "closing": "正在關閉...", + "description": "實時查看初始化過程。", + "exitError": "進程退出時出錯(退出碼:{code})", + "exitSuccess": "進程成功完成(退出碼:{code})", + "processError": "進程錯誤", + "starting": "正在啟動初始化...", + "status": { + "completed": "已完成", + "error": "錯誤", + "idle": "空閒", + "running": "運行中" + }, + "title": "初始化終端", + "waiting": "等待初始化開始..." } } } diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index 4d92a0880..cbeb00984 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -883,6 +883,28 @@ "cannotDeleteTitle": "至少保留一組設定", "cannotDeleteDesc": "內建 Agent 至少需要一組設定。", "noAgent": "請選擇要管理的 Agent。" + }, + "initialize": "初始化", + "initializeDescription": "已在終端中打開並執行初始化命令", + "initializeFailed": "初始化失敗", + "initializeSuccess": "初始化已啟動", + "initializing": "正在初始化...", + "terminal": { + "close": "關閉", + "closing": "正在關閉...", + "description": "實時查看初始化過程。", + "exitError": "進程退出時出錯(退出碼:{code})", + "exitSuccess": "進程成功完成(退出碼:{code})", + "processError": "進程錯誤", + "starting": "正在啟動初始化...", + "status": { + "completed": "已完成", + "error": "錯誤", + "idle": "空閒", + "running": "運行中" + }, + "title": "初始化終端", + "waiting": "等待初始化開始..." } } } diff --git a/src/shared/types/presenters/legacy.presenters.d.ts b/src/shared/types/presenters/legacy.presenters.d.ts index 4cad317aa..f4772e447 100644 --- a/src/shared/types/presenters/legacy.presenters.d.ts +++ b/src/shared/types/presenters/legacy.presenters.d.ts @@ -532,6 +532,7 @@ export interface IConfigPresenter { ): Promise removeCustomAcpAgent(agentId: string): Promise setCustomAcpAgentEnabled(agentId: string, enabled: boolean): Promise + initializeAcpAgent(agentId: string, isBuiltin: boolean): Promise getMcpConfHelper(): any // Used to get MCP configuration helper getModelConfig(modelId: string, providerId?: string): ModelConfig setModelConfig( diff --git a/test/main/presenter/mcpClient.test.ts b/test/main/presenter/mcpClient.test.ts index 2afc75e45..3799f851c 100644 --- a/test/main/presenter/mcpClient.test.ts +++ b/test/main/presenter/mcpClient.test.ts @@ -119,12 +119,6 @@ describe('McpClient Runtime Command Processing Tests', () => { mockGenerateCompletionStandalone.mockReset() mockGetProviderModels.mockReset() mockGetCustomModels.mockReset() - - // Mock runtime paths to exist - mockFsExistsSync.mockImplementation((filePath: string | Buffer | URL) => { - const pathStr = String(filePath) - return pathStr.includes('runtime/bun') || pathStr.includes('runtime/uv') - }) }) afterEach(() => { @@ -152,36 +146,6 @@ describe('McpClient Runtime Command Processing Tests', () => { expect(processedCommand.args).toEqual(['x', '-y', '@modelcontextprotocol/server-everything']) }) - it('should handle npx command with runtime path replacement', () => { - const serverConfig = { - type: 'stdio', - command: 'npx', - args: ['-y', '@modelcontextprotocol/server-everything'] - } - - const client = new McpClient('everything', serverConfig) - - // Mock the runtime path for testing - const bunRuntimePath = path - .join('/mock/app/runtime/bun') - .replace('app.asar', 'app.asar.unpacked') - ;(client as any).bunRuntimePath = bunRuntimePath - - const processedCommand = (client as any).processCommandWithArgs('npx', [ - '-y', - '@modelcontextprotocol/server-everything' - ]) - - // Should use the runtime path - const expectedBunPath = - process.platform === 'win32' - ? path.join(bunRuntimePath, 'bun.exe') - : path.join(bunRuntimePath, 'bun') - - expect(processedCommand.command).toBe(expectedBunPath) - expect(processedCommand.args).toEqual(['x', '-y', '@modelcontextprotocol/server-everything']) - }) - it('should handle npx in command path correctly', () => { const serverConfig = { type: 'stdio', @@ -332,18 +296,6 @@ describe('McpClient Runtime Command Processing Tests', () => { }) describe('Runtime Path Detection', () => { - it('should detect bun runtime when files exist', () => { - mockFsExistsSync.mockImplementation((filePath: string | Buffer | URL) => { - const pathStr = String(filePath) - return pathStr.includes('runtime/bun/bun') - }) - - const client = new McpClient('test', { type: 'stdio' }) - - // Check if bun runtime path is set - expect((client as any).bunRuntimePath).toBeTruthy() - }) - it('should detect uv runtime when files exist', () => { mockFsExistsSync.mockImplementation((filePath: string | Buffer | URL) => { const pathStr = String(filePath)