Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build/nsis-installer.nsh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 38 additions & 0 deletions src/main/lib/runtimeHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
}
}
185 changes: 183 additions & 2 deletions src/main/presenter/configPresenter/acpInitHelper.ts
Original file line number Diff line number Diff line change
@@ -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<AcpBuiltinAgentId, InitCommandConfig> = {
'kimi-cli': {
commands: ['uv tool run --from kimi-cli kimi'],
Expand All @@ -24,7 +64,7 @@ const BUILTIN_INIT_COMMANDS: Record<AcpBuiltinAgentId, InitCommandConfig> = {
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'
}
}
Expand All @@ -37,6 +77,100 @@ class AcpInitHelper {
this.runtimeHelper.initializeRuntimes()
}

/**
* Check if an external dependency is available
*/
private async checkExternalDependency(dep: ExternalDependency): Promise<boolean> {
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<ExternalDependency[]> {
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
*/
Expand All @@ -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)
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion src/main/presenter/configPresenter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1057,14 +1057,19 @@ 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,
npmRegistry,
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()
Expand Down
1 change: 1 addition & 0 deletions src/preload/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand Down
Loading