diff --git a/build/nsis-installer.nsh b/build/nsis-installer.nsh index ba2dd4c9e..190660dc1 100644 --- a/build/nsis-installer.nsh +++ b/build/nsis-installer.nsh @@ -130,7 +130,7 @@ inetc::get /CAPTION " " /BANNER "Downloading Microsoft Visual C++ Redistributable ($2)..." "$5" "$6" ExecWait "$6 /install /norestart" ; vc_redist exit code is unreliable, so we re-check registry - + Push $2 ; Pass arch to checkVCRedist again Call checkVCRedist Pop $2 diff --git a/src/main/lib/runtimeHelper.ts b/src/main/lib/runtimeHelper.ts index 8ab087bfb..6ac255731 100644 --- a/src/main/lib/runtimeHelper.ts +++ b/src/main/lib/runtimeHelper.ts @@ -319,4 +319,42 @@ export class RuntimeHelper { return [`${homeDir}\\.cargo\\bin`, `${homeDir}\\.local\\bin`] } } + + /** + * Check if the application is installed in a Windows system directory + * System directories include Program Files and Program Files (x86) + * @returns true if installed in system directory, false otherwise + */ + public isInstalledInSystemDirectory(): boolean { + if (process.platform !== 'win32') { + return false + } + + const appPath = app.getAppPath() + const normalizedPath = appPath.toLowerCase() + + // Check if app is installed in Program Files or Program Files (x86) + const isSystemDir = + normalizedPath.includes('program files') || normalizedPath.includes('program files (x86)') + + if (isSystemDir) { + console.log('[RuntimeHelper] Application is installed in system directory:', appPath) + } + + return isSystemDir + } + + /** + * Get user npm prefix path for Windows + * Returns the path where npm should install global packages when app is in system directory + * @returns User npm prefix path or null if not applicable + */ + public getUserNpmPrefix(): string | null { + if (process.platform !== 'win32') { + return null + } + + const appDataPath = app.getPath('appData') + return path.join(appDataPath, 'npm') + } } diff --git a/src/main/presenter/configPresenter/acpInitHelper.ts b/src/main/presenter/configPresenter/acpInitHelper.ts index 92005b095..dbd547d4f 100644 --- a/src/main/presenter/configPresenter/acpInitHelper.ts +++ b/src/main/presenter/configPresenter/acpInitHelper.ts @@ -1,15 +1,55 @@ import * as path from 'path' +import * as fs from 'fs' +import { exec } from 'child_process' +import { promisify } from 'util' import { type WebContents } from 'electron' import type { AcpBuiltinAgentId, AcpAgentConfig, AcpAgentProfile } from '@shared/presenter' import { spawn } from '@homebridge/node-pty-prebuilt-multiarch' import type { IPty } from '@homebridge/node-pty-prebuilt-multiarch' import { RuntimeHelper } from '@/lib/runtimeHelper' +const execAsync = promisify(exec) + interface InitCommandConfig { commands: string[] description: string } +interface ExternalDependency { + name: string + description: string + platform?: string[] + checkCommand?: string + checkPaths?: string[] + installCommands?: { + winget?: string + chocolatey?: string + scoop?: string + } + downloadUrl?: string + requiredFor?: string[] +} + +const EXTERNAL_DEPENDENCIES: ExternalDependency[] = [ + { + name: 'Git Bash', + description: 'Git for Windows includes Git Bash', + platform: ['win32'], + checkCommand: 'git --version', + checkPaths: [ + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Program Files (x86)\\Git\\bin\\bash.exe' + ], + installCommands: { + winget: 'winget install Git.Git', + chocolatey: 'choco install git', + scoop: 'scoop install git' + }, + downloadUrl: 'https://git-scm.com/download/win', + requiredFor: ['claude-code-acp'] + } +] + const BUILTIN_INIT_COMMANDS: Record = { 'kimi-cli': { commands: ['uv tool run --from kimi-cli kimi'], @@ -24,7 +64,7 @@ const BUILTIN_INIT_COMMANDS: Record = { description: 'Initialize Claude Code ACP' }, 'codex-acp': { - commands: ['npm i -g @zed-industries/codex-acp', 'npm install -g @openai/codex-sdk', 'codex'], + commands: ['npm i -g @zed-industries/codex-acp', 'npm install -g @openai/codex', 'codex'], description: 'Initialize Codex CLI ACP' } } @@ -37,6 +77,100 @@ class AcpInitHelper { this.runtimeHelper.initializeRuntimes() } + /** + * Check if an external dependency is available + */ + private async checkExternalDependency(dep: ExternalDependency): Promise { + const platform = process.platform + + // Check if dependency supports current platform + if (dep.platform && !dep.platform.includes(platform)) { + console.log(`[ACP Init] Dependency ${dep.name} not required on platform ${platform}`) + return true // Not required on this platform, consider it available + } + + // Method 1: Check via command + if (dep.checkCommand) { + try { + const { stdout } = await execAsync(dep.checkCommand, { timeout: 5000 }) + if (stdout && stdout.trim().length > 0) { + console.log(`[ACP Init] Dependency ${dep.name} found via command: ${dep.checkCommand}`) + return true + } + } catch { + console.log(`[ACP Init] Dependency ${dep.name} not found via command: ${dep.checkCommand}`) + } + } + + // Method 2: Check via paths + if (dep.checkPaths && dep.checkPaths.length > 0) { + for (const checkPath of dep.checkPaths) { + try { + if (fs.existsSync(checkPath)) { + console.log(`[ACP Init] Dependency ${dep.name} found at path: ${checkPath}`) + return true + } + } catch { + // Continue checking other paths + } + } + } + + // Method 3: Use system tools to find command + if (dep.checkCommand) { + try { + const commandName = dep.checkCommand.split(' ')[0] + let findCommand: string + + if (platform === 'win32') { + findCommand = `where.exe ${commandName}` + } else { + findCommand = `which ${commandName}` + } + + const { stdout } = await execAsync(findCommand, { timeout: 5000 }) + if (stdout && stdout.trim().length > 0) { + console.log(`[ACP Init] Dependency ${dep.name} found via system tool: ${findCommand}`) + return true + } + } catch { + // Command not found + } + } + + console.log(`[ACP Init] Dependency ${dep.name} not found`) + return false + } + + /** + * Check required dependencies for an agent + */ + private async checkRequiredDependencies(agentId: string): Promise { + const platform = process.platform + const missingDeps: ExternalDependency[] = [] + + // Find dependencies required for this agent + const requiredDeps = EXTERNAL_DEPENDENCIES.filter( + (dep) => dep.requiredFor && dep.requiredFor.includes(agentId) + ) + + console.log(`[ACP Init] Checking dependencies for agent ${agentId}:`, { + totalDeps: requiredDeps.length, + platform + }) + + // Check each dependency + for (const dep of requiredDeps) { + const isAvailable = await this.checkExternalDependency(dep) + if (!isAvailable) { + missingDeps.push(dep) + console.log(`[ACP Init] Missing dependency: ${dep.name}`) + } + } + + return missingDeps + } + /** * Initialize a builtin ACP agent with terminal output streaming */ @@ -57,6 +191,23 @@ class AcpInitHelper { profileName: profile.name }) + // Check external dependencies before initialization + const missingDeps = await this.checkRequiredDependencies(agentId) + if (missingDeps.length > 0) { + console.log('[ACP Init] Missing dependencies detected, blocking initialization:', { + agentId, + missingCount: missingDeps.length + }) + if (webContents && !webContents.isDestroyed()) { + webContents.send('external-deps-required', { + agentId, + missingDeps + }) + } + // Stop initialization - user must install dependencies first + return null + } + const initConfig = BUILTIN_INIT_COMMANDS[agentId] if (!initConfig) { console.error('[ACP Init] Unknown builtin agent:', agentId) @@ -177,7 +328,7 @@ class AcpInitHelper { if (platform === 'win32') { shell = 'powershell.exe' - shellArgs = ['-NoLogo'] + shellArgs = ['-NoLogo', '-ExecutionPolicy', 'Bypass'] } else { // Use user's default shell or bash/zsh shell = process.env.SHELL || '/bin/bash' @@ -394,6 +545,36 @@ class AcpInitHelper { env.PIP_INDEX_URL = uvRegistry console.log('[ACP Init] Set UV registry:', uvRegistry) } + + // On Windows, if app is installed in system directory, set npm prefix to user directory + // to avoid permission issues when installing global packages + if (process.platform === 'win32' && this.runtimeHelper.isInstalledInSystemDirectory()) { + const userNpmPrefix = this.runtimeHelper.getUserNpmPrefix() + + if (userNpmPrefix) { + env.npm_config_prefix = userNpmPrefix + env.NPM_CONFIG_PREFIX = userNpmPrefix + console.log( + '[ACP Init] Set NPM prefix to user directory (system install detected):', + userNpmPrefix + ) + + // Add user npm bin directory to PATH + const pathKey = 'Path' + const separator = ';' + const existingPath = env[pathKey] || '' + const userNpmBinPath = userNpmPrefix + + // Ensure the user npm bin path is at the beginning of PATH + if (existingPath) { + env[pathKey] = [userNpmBinPath, existingPath].filter(Boolean).join(separator) + } else { + env[pathKey] = userNpmBinPath + } + + console.log('[ACP Init] Added user npm bin directory to PATH:', userNpmBinPath) + } + } } // Add custom environment variables from profile diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts index 69871cb47..a5345866b 100644 --- a/src/main/presenter/configPresenter/index.ts +++ b/src/main/presenter/configPresenter/index.ts @@ -1057,7 +1057,7 @@ export class ConfigPresenter implements IConfigPresenter { throw new Error(`No active profile found for agent: ${agentId}`) } - await initializeBuiltinAgent( + const result = await initializeBuiltinAgent( agentId as AcpBuiltinAgentId, activeProfile, useBuiltinRuntime, @@ -1065,6 +1065,11 @@ export class ConfigPresenter implements IConfigPresenter { uvRegistry, webContents ) + // If initialization returns null, it means dependencies are missing + // The event has already been sent to frontend, just return without error + if (result === null) { + return + } } else { // Get custom agent const customs = await this.getAcpCustomAgents() diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 5fb4d773a..195757e54 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -6,6 +6,7 @@ declare global { api: { copyText(text: string): void copyImage(image: string): void + readClipboardText(): string getPathForFile(file: File): string getWindowId(): number | null getWebContentsId(): number diff --git a/src/preload/index.ts b/src/preload/index.ts index 92e072b86..f3d34a69b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -22,6 +22,9 @@ const api = { const img = nativeImage.createFromDataURL(image) clipboard.writeImage(img) }, + readClipboardText: () => { + return clipboard.readText() + }, getPathForFile: (file: File) => { return webUtils.getPathForFile(file) }, diff --git a/src/renderer/settings/components/AcpDependencyDialog.vue b/src/renderer/settings/components/AcpDependencyDialog.vue new file mode 100644 index 000000000..486cf52c6 --- /dev/null +++ b/src/renderer/settings/components/AcpDependencyDialog.vue @@ -0,0 +1,161 @@ + + + diff --git a/src/renderer/settings/components/AcpSettings.vue b/src/renderer/settings/components/AcpSettings.vue index 2c3527333..adf530f39 100644 --- a/src/renderer/settings/components/AcpSettings.vue +++ b/src/renderer/settings/components/AcpSettings.vue @@ -260,6 +260,13 @@ + + @@ -296,6 +303,7 @@ import { import AcpProfileDialog from './AcpProfileDialog.vue' import AcpProfileManagerDialog from './AcpProfileManagerDialog.vue' import AcpTerminalDialog from './AcpTerminalDialog.vue' +import AcpDependencyDialog from './AcpDependencyDialog.vue' const { t } = useI18n() const { toast } = useToast() @@ -316,6 +324,23 @@ const builtinPending = reactive>({}) const customPending = reactive>({}) const initializing = reactive>({}) const terminalDialogOpen = ref(false) +const dependencyDialogOpen = ref(false) +const missingDependencies = ref< + Array<{ + name: string + description: string + platform?: string[] + checkCommand?: string + checkPaths?: string[] + installCommands?: { + winget?: string + chocolatey?: string + scoop?: string + } + downloadUrl?: string + requiredFor?: string[] + }> +>([]) const profileDialogState = reactive({ open: false, @@ -679,6 +704,13 @@ const setInitializing = (agentId: string, isBuiltin: boolean, state: boolean) => } } +const handleDependenciesRequired = (dependencies: typeof missingDependencies.value) => { + console.log('[AcpSettings] Dependencies required:', dependencies) + missingDependencies.value = dependencies + dependencyDialogOpen.value = true + terminalDialogOpen.value = false +} + const handleInitializeAgent = async (agentId: string, isBuiltin: boolean) => { if (isInitializing(agentId, isBuiltin)) return diff --git a/src/renderer/settings/components/AcpTerminalDialog.vue b/src/renderer/settings/components/AcpTerminalDialog.vue index a8e87e27d..f2f3e05ea 100644 --- a/src/renderer/settings/components/AcpTerminalDialog.vue +++ b/src/renderer/settings/components/AcpTerminalDialog.vue @@ -1,35 +1,31 @@