From ea959e577bb7f499a1eb32d44be703f4c32cd15b Mon Sep 17 00:00:00 2001 From: yushen Date: Fri, 16 Jan 2026 13:15:55 +0800 Subject: [PATCH] Fix polling detection to check all agent components - Change `installed` check from primary command only to all required commands - Agents with multiple components (e.g., claude-code requiring both CLI and ACP) are now only marked as installed when all components are present - This fixes the issue where polling would stop prematurely when only the primary command (e.g., `claude`) was detected but `claude-code-acp` was not - Also includes related changes for terminal-based installation flow Co-Authored-By: Claude Opus 4.5 --- src/main/ipc/handlers.ts | 9 +- src/main/utils/agent-check.ts | 9 +- src/main/utils/agent-install.ts | 439 +++++--------------- src/renderer/src/components/Settings.tsx | 211 +++++----- tests/unit/main/utils/agent-install.test.ts | 319 ++++++++++++++ 5 files changed, 547 insertions(+), 440 deletions(-) create mode 100644 tests/unit/main/utils/agent-install.test.ts diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index c9ed8a412..cfd15f029 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -2,7 +2,7 @@ * IPC handlers for main process * Registers all IPC handlers for communication with renderer process */ -import { ipcMain, dialog, clipboard, shell, BrowserWindow } from 'electron' +import { ipcMain, dialog, clipboard, shell } from 'electron' import { IPC_CHANNELS } from '../../shared/ipc-channels' import { DEFAULT_AGENTS } from '../config/defaults' import { checkAgents, checkAgent } from '../utils/agent-check' @@ -205,14 +205,11 @@ export function registerIPCHandlers(conductor: Conductor): void { }) // --- Agent installation handler --- + // Opens Terminal with install command (user executes manually) ipcMain.handle(IPC_CHANNELS.AGENT_INSTALL, async (_event, agentId: string) => { try { - const window = BrowserWindow.getFocusedWindow() - if (!window) { - throw new Error('No focused window') - } - return await installAgent({ window, agentId }) + return await installAgent(agentId) } catch (err) { throw new Error(extractErrorMessage(err)) } diff --git a/src/main/utils/agent-check.ts b/src/main/utils/agent-check.ts index 963924058..e85ed4b8c 100644 --- a/src/main/utils/agent-check.ts +++ b/src/main/utils/agent-check.ts @@ -27,7 +27,7 @@ export interface AgentCheckResult { // Install hints for each agent const INSTALL_HINTS: Record = { 'claude-code': 'npm install -g @zed-industries/claude-code-acp', - opencode: 'go install github.com/anomalyco/opencode@latest', + opencode: 'curl -fsSL https://opencode.ai/install | bash', codex: 'npm install -g @openai/codex', gemini: 'npm install -g @google/gemini-cli' } @@ -80,11 +80,16 @@ export async function checkAgent(agentId: string): Promise c.command === config.command) + // Only consider installed when all related commands exist + // This ensures agents with multiple components (e.g., claude-code requiring both CLI and ACP) + // are not marked as installed until all components are present + const allCommandsInstalled = commandChecks.every((c) => c.exists) + return { id: agentId, name: config.name, command: config.command, - installed: primaryResult?.exists ?? false, + installed: allCommandsInstalled, path: primaryResult?.path, installHint: INSTALL_HINTS[agentId], commands: commandChecks.map(({ command, path }) => ({ command, path })) diff --git a/src/main/utils/agent-install.ts b/src/main/utils/agent-install.ts index cd39a6a34..23e0d3573 100644 --- a/src/main/utils/agent-install.ts +++ b/src/main/utils/agent-install.ts @@ -1,374 +1,143 @@ /** * Agent installation utilities - * Handles one-click installation of AI agents + * Opens system Terminal with install command for user to execute */ import { spawn } from 'child_process' import { platform } from 'os' -import type { BrowserWindow } from 'electron' -import { IPC_CHANNELS } from '../../shared/ipc-channels' -import type { InstallStep, InstallResult } from '../../shared/electron-api' -import { commandExists } from './agent-check' +import type { InstallResult } from '../../shared/electron-api' -interface InstallOptions { - window: BrowserWindow - agentId: string +// Install commands for each agent +// Claude Code and Codex need both CLI and ACP installed +export const INSTALL_COMMANDS: Record = { + // Claude Code: Install official CLI first, then ACP adapter + 'claude-code': + 'curl -fsSL https://claude.ai/install.sh | bash && npm install -g @zed-industries/claude-code-acp', + // OpenCode: Official install script + opencode: 'curl -fsSL https://opencode.ai/install | bash', + // Codex: Install CLI and ACP adapter together + codex: 'npm install -g @openai/codex @zed-industries/codex-acp', + // Gemini: Single npm package + gemini: 'npm install -g @google/gemini-cli' } -type ProgressCallback = ( - step: InstallStep, - status: string, - message?: string, - error?: string -) => void - /** - * Execute a command and stream output + * Open the system Terminal and type a command (without executing) + * User will see the command and can press Enter to run it */ -function spawnWithProgress( - command: string, - args: string[], - onProgress: (data: string) => void, - useShell: boolean = false +export function openTerminalWithCommand( + command: string ): Promise<{ success: boolean; error?: string }> { return new Promise((resolve) => { - // Ensure common paths are in PATH for npm, node, etc. - const enhancedPath = `/usr/local/bin:/opt/homebrew/bin:${process.env.PATH || ''}` + const os = platform() - console.log(`[agent-install] Spawning: ${command} ${args.join(' ')} (shell: ${useShell})`) + try { + if (os === 'darwin') { + // macOS: Use osascript to open Terminal.app and execute command + const script = ` +tell application "Terminal" + activate + do script "${command.replace(/"/g, '\\"')}" +end tell` + const proc = spawn('osascript', ['-e', script]) - const proc = spawn(command, args, { - shell: useShell, - env: { - ...process.env, - PATH: enhancedPath, - HOME: process.env.HOME || '' - }, - stdio: ['pipe', 'pipe', 'pipe'] - }) + proc.on('close', (code) => { + if (code === 0) { + resolve({ success: true }) + } else { + resolve({ success: false, error: `osascript exited with code ${code}` }) + } + }) - let errorOutput = '' - let stdoutOutput = '' + proc.on('error', (err) => { + resolve({ success: false, error: err.message }) + }) + } else if (os === 'win32') { + // Windows: Open CMD and run command + const proc = spawn('cmd.exe', ['/c', 'start', 'cmd.exe', '/K', command], { + shell: true + }) - proc.stdout?.on('data', (data) => { - const text = data.toString() - stdoutOutput += text - console.log(`[agent-install] stdout: ${text}`) - onProgress(text) - }) + proc.on('close', (code) => { + if (code === 0) { + resolve({ success: true }) + } else { + resolve({ success: false, error: `cmd exited with code ${code}` }) + } + }) - proc.stderr?.on('data', (data) => { - const text = data.toString() - errorOutput += text - console.log(`[agent-install] stderr: ${text}`) - onProgress(text) - }) - - proc.on('close', (code) => { - console.log(`[agent-install] Process closed with code: ${code}`) - console.log(`[agent-install] stdout total: ${stdoutOutput}`) - console.log(`[agent-install] stderr total: ${errorOutput}`) - if (code === 0) { - resolve({ success: true }) + proc.on('error', (err) => { + resolve({ success: false, error: err.message }) + }) } else { - resolve({ success: false, error: errorOutput || `Exit code: ${code}` }) - } - }) + // Linux: Try common terminal emulators in order of preference + const terminals = [ + { cmd: 'gnome-terminal', args: ['--', 'bash', '-c', `${command}; exec bash`] }, + { cmd: 'konsole', args: ['-e', 'bash', '-c', `${command}; exec bash`] }, + { cmd: 'xfce4-terminal', args: ['-e', `bash -c "${command}; exec bash"`] }, + { cmd: 'xterm', args: ['-e', `bash -c "${command}; read -p 'Press Enter to close...'"`] } + ] - proc.on('error', (err) => { - console.log(`[agent-install] Process error: ${err.message}`) - resolve({ success: false, error: err.message }) - }) + tryTerminals(terminals, 0, resolve) + } + } catch (err) { + resolve({ + success: false, + error: err instanceof Error ? err.message : 'Unknown error opening terminal' + }) + } }) } /** - * Install Claude Code CLI using official install script + * Try to launch Linux terminals one by one until one succeeds */ -async function installClaudeCLI( - onProgress: (message: string) => void -): Promise<{ success: boolean; error?: string }> { - const isWindows = platform() === 'win32' - - if (isWindows) { - // Windows: use PowerShell - return spawnWithProgress( - 'powershell', - ['-Command', 'irm https://claude.ai/install.ps1 | iex'], - onProgress, - true - ) - } else { - // macOS/Linux: use curl + bash with full path - return spawnWithProgress( - '/bin/bash', - ['-c', 'curl -fsSL https://claude.ai/install.sh | /bin/bash'], - onProgress, - false - ) - } -} - -/** - * Install claude-code-acp via npm - */ -async function installClaudeCodeACP( - onProgress: (message: string) => void -): Promise<{ success: boolean; error?: string }> { - return spawnWithProgress( - 'npm', - ['install', '-g', '@zed-industries/claude-code-acp'], - onProgress, - true - ) -} - -/** - * Install OpenCode using official install script - */ -async function installOpenCodeCLI( - onProgress: (message: string) => void -): Promise<{ success: boolean; error?: string }> { - const isWindows = platform() === 'win32' - - if (isWindows) { - // Windows: use PowerShell - return spawnWithProgress( - 'powershell', - ['-Command', 'irm https://opencode.ai/install.ps1 | iex'], - onProgress, - true - ) - } else { - // macOS/Linux: use curl + bash with full path - return spawnWithProgress( - '/bin/bash', - ['-c', 'curl -fsSL https://opencode.ai/install | /bin/bash'], - onProgress, - false - ) - } -} - -/** - * Main installation function for Claude Code - */ -async function installClaudeCode(options: InstallOptions): Promise { - const { window, agentId } = options - - const sendProgress: ProgressCallback = (step, status, message, error) => { - window.webContents.send(IPC_CHANNELS.AGENT_INSTALL_PROGRESS, { - agentId, - step, - status, - message, - error - }) +function tryTerminals( + terminals: Array<{ cmd: string; args: string[] }>, + index: number, + resolve: (result: { success: boolean; error?: string }) => void +): void { + if (index >= terminals.length) { + resolve({ success: false, error: 'No supported terminal emulator found' }) + return } - try { - // Step 1: Check npm - sendProgress('check-npm', 'started') - const npmCheck = await commandExists('npm') - if (!npmCheck.exists) { - const errorMsg = 'Node.js is required. Please install from https://nodejs.org' - sendProgress('check-npm', 'error', undefined, errorMsg) - return { success: false, error: errorMsg } - } - sendProgress('check-npm', 'completed') + const { cmd, args } = terminals[index] + const proc = spawn(cmd, args, { detached: true, stdio: 'ignore' }) - // Step 2: Install Claude Code CLI (if not already installed) - const claudeCheck = await commandExists('claude') - if (!claudeCheck.exists) { - sendProgress('install-cli', 'started') - const cliResult = await installClaudeCLI((msg) => - sendProgress('install-cli', 'progress', msg) - ) + proc.on('error', () => { + // This terminal not found, try next one + tryTerminals(terminals, index + 1, resolve) + }) - if (!cliResult.success) { - // CLI installation failure is not fatal - ACP might still work - console.warn('[agent-install] Claude CLI installation failed:', cliResult.error) - sendProgress('install-cli', 'error', undefined, cliResult.error) - } else { - sendProgress('install-cli', 'completed') - } - } else { - // Already installed, skip - sendProgress('install-cli', 'completed', 'Already installed') - } + // If spawn succeeds, consider it success (we detach so no close event) + proc.unref() - // Step 3: Install claude-code-acp - sendProgress('install-acp', 'started') - const acpResult = await installClaudeCodeACP((msg) => - sendProgress('install-acp', 'progress', msg) - ) - - if (!acpResult.success) { - const errorMsg = formatInstallError(acpResult.error || 'Unknown error') - sendProgress('install-acp', 'error', undefined, errorMsg) - return { success: false, error: errorMsg } - } - - sendProgress('install-acp', 'completed') - return { success: true } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : 'Unknown error' - return { success: false, error: errorMsg } - } -} - -/** - * Install Codex CLI via npm - */ -async function installCodexCLI( - onProgress: (message: string) => void -): Promise<{ success: boolean; error?: string }> { - return spawnWithProgress('npm', ['install', '-g', '@openai/codex'], onProgress, true) -} - -/** - * Install codex-acp via npm - */ -async function installCodexACP( - onProgress: (message: string) => void -): Promise<{ success: boolean; error?: string }> { - return spawnWithProgress('npm', ['install', '-g', '@zed-industries/codex-acp'], onProgress, true) -} - -/** - * Main installation function for Codex - */ -async function installCodex(options: InstallOptions): Promise { - const { window, agentId } = options - - const sendProgress: ProgressCallback = (step, status, message, error) => { - window.webContents.send(IPC_CHANNELS.AGENT_INSTALL_PROGRESS, { - agentId, - step, - status, - message, - error - }) - } - - try { - // Step 1: Check npm - sendProgress('check-npm', 'started') - const npmCheck = await commandExists('npm') - if (!npmCheck.exists) { - const errorMsg = 'Node.js is required. Please install from https://nodejs.org' - sendProgress('check-npm', 'error', undefined, errorMsg) - return { success: false, error: errorMsg } - } - sendProgress('check-npm', 'completed') - - // Step 2: Install Codex CLI (if not already installed) - const codexCheck = await commandExists('codex') - if (!codexCheck.exists) { - sendProgress('install-cli', 'started') - const cliResult = await installCodexCLI((msg) => sendProgress('install-cli', 'progress', msg)) - - if (!cliResult.success) { - // CLI installation failure is not fatal - ACP might still work - console.warn('[agent-install] Codex CLI installation failed:', cliResult.error) - sendProgress('install-cli', 'error', undefined, cliResult.error) - } else { - sendProgress('install-cli', 'completed') - } - } else { - // Already installed, skip - sendProgress('install-cli', 'completed', 'Already installed') - } - - // Step 3: Install codex-acp - sendProgress('install-acp', 'started') - const acpResult = await installCodexACP((msg) => sendProgress('install-acp', 'progress', msg)) - - if (!acpResult.success) { - const errorMsg = formatInstallError(acpResult.error || 'Unknown error') - sendProgress('install-acp', 'error', undefined, errorMsg) - return { success: false, error: errorMsg } - } - - sendProgress('install-acp', 'completed') - return { success: true } - } catch (error) { - const errorMsg = error instanceof Error ? error.message : 'Unknown error' - return { success: false, error: errorMsg } - } -} - -/** - * Main installation function for OpenCode - */ -async function installOpenCode(options: InstallOptions): Promise { - const { window, agentId } = options - - console.log(`[agent-install] Starting OpenCode installation`) - - const sendProgress = (status: string, message?: string, error?: string): void => { - console.log(`[agent-install] OpenCode progress: ${status} - ${message || ''} - ${error || ''}`) - window.webContents.send(IPC_CHANNELS.AGENT_INSTALL_PROGRESS, { - agentId, - step: 'install-cli', - status, - message, - error - }) - } - - try { - sendProgress('started') - - console.log(`[agent-install] Calling installOpenCodeCLI...`) - const result = await installOpenCodeCLI((msg) => sendProgress('progress', msg)) - console.log(`[agent-install] installOpenCodeCLI result:`, result) - - if (!result.success) { - const errorMsg = formatInstallError(result.error || 'Unknown error') - sendProgress('error', undefined, errorMsg) - return { success: false, error: errorMsg } - } - - sendProgress('completed') - return { success: true } - } catch (error) { - console.error(`[agent-install] OpenCode installation error:`, error) - const errorMsg = error instanceof Error ? error.message : 'Unknown error' - sendProgress('error', undefined, errorMsg) - return { success: false, error: errorMsg } - } + // Give it a moment to fail if the command doesn't exist + setTimeout(() => { + // If we got here without error, assume success + resolve({ success: true }) + }, 100) } /** * Main entry point for agent installation + * Opens Terminal with the install command for the user to execute */ -export async function installAgent(options: InstallOptions): Promise { - const { agentId } = options +export async function installAgent(agentId: string): Promise { + const command = INSTALL_COMMANDS[agentId] - switch (agentId) { - case 'claude-code': - return installClaudeCode(options) - case 'opencode': - return installOpenCode(options) - case 'codex': - return installCodex(options) - default: - return { success: false, error: `Installation not supported for: ${agentId}` } + if (!command) { + return { success: false, error: `Installation not supported for: ${agentId}` } } -} -/** - * Format installation error messages for user display - */ -function formatInstallError(error: string): string { - if (error.includes('EACCES') || error.includes('permission denied')) { - return 'Permission denied. Please check your permissions and try again.' + console.log(`[agent-install] Opening terminal with command: ${command}`) + const result = await openTerminalWithCommand(command) + + if (!result.success) { + console.error(`[agent-install] Failed to open terminal:`, result.error) + return { success: false, error: result.error } } - if (error.includes('Could not resolve host') || error.includes('ENOTFOUND')) { - return 'Network error. Please check your internet connection and try again.' - } - if (error.includes('ENOENT')) { - return 'Command not found. Please ensure required tools are installed.' - } - return error + + // Success means terminal was opened (not that installation completed) + return { success: true } } diff --git a/src/renderer/src/components/Settings.tsx b/src/renderer/src/components/Settings.tsx index d10b7bd11..49bd11154 100644 --- a/src/renderer/src/components/Settings.tsx +++ b/src/renderer/src/components/Settings.tsx @@ -2,12 +2,8 @@ * Settings component - simplified agent selector * Using Linear-style design: minimal UI, direct interactions */ -import React, { useState, useEffect } from 'react' -import type { - AgentCheckResult, - InstallProgressEvent, - InstallStep -} from '../../../shared/electron-api' +import React, { useState, useEffect, useRef, useCallback } from 'react' +import type { AgentCheckResult } from '../../../shared/electron-api' import { useTheme } from '../contexts/ThemeContext' import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group' @@ -40,8 +36,7 @@ type ThemeMode = 'light' | 'dark' | 'system' interface InstallStatus { agentId: string | null - state: 'idle' | 'installing' | 'success' | 'error' - currentStep?: InstallStep + state: 'idle' | 'waiting' | 'success' | 'error' error?: string } @@ -52,6 +47,9 @@ const AGENT_LIST = [ { id: 'codex', name: 'Codex CLI (ACP)' } ] +// Polling interval for checking installation status (ms) +const INSTALL_CHECK_INTERVAL = 2500 + export function Settings({ isOpen, onClose, @@ -66,6 +64,7 @@ export function Settings({ state: 'idle' }) const { mode, setMode } = useTheme() + const pollingIntervalRef = useRef | null>(null) useEffect(() => { if (isOpen) { @@ -73,50 +72,58 @@ export function Settings({ } }, [isOpen]) - // Listen to install progress events + // Cleanup polling on unmount or when dialog closes useEffect(() => { - const unsubscribe = window.electronAPI.onInstallProgress((event: InstallProgressEvent) => { - if (event.status === 'error') { - setInstallStatus({ - agentId: event.agentId, - state: 'error', - currentStep: event.step, - error: event.error - }) - } else if (event.status === 'completed' && isLastInstallStep(event.agentId, event.step)) { - setInstallStatus({ agentId: event.agentId, state: 'success' }) - // Refresh this agent after installation - refreshAgent(event.agentId) - } else { - setInstallStatus({ - agentId: event.agentId, - state: 'installing', - currentStep: event.step - }) + return () => { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current) + pollingIntervalRef.current = null } - }) - - return unsubscribe + } }, []) - // Refresh a single agent - async function refreshAgent(agentId: string): Promise { - setCheckingAgents((prev) => new Set(prev).add(agentId)) - try { - const result = await window.electronAPI.checkAgent(agentId) - if (result) { - setAgentResults((prev) => new Map(prev).set(agentId, result)) - } - } catch (err) { - console.error(`Failed to check agent ${agentId}:`, err) - } finally { - setCheckingAgents((prev) => { - const next = new Set(prev) - next.delete(agentId) - return next - }) + // Stop polling when dialog closes + useEffect(() => { + if (!isOpen && pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current) + pollingIntervalRef.current = null + // Reset install status when closing + setInstallStatus({ agentId: null, state: 'idle' }) } - } + }, [isOpen]) + + // Polling logic for checking installation + const startPolling = useCallback( + (agentId: string) => { + // Clear any existing polling + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current) + } + + pollingIntervalRef.current = setInterval(async () => { + try { + const result = await window.electronAPI.checkAgent(agentId) + if (result?.installed) { + // Installation detected! Stop polling and update state + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current) + pollingIntervalRef.current = null + } + setAgentResults((prev) => new Map(prev).set(agentId, result)) + setInstallStatus({ agentId, state: 'success' }) + + // Clear success state after a moment + setTimeout(() => { + setInstallStatus({ agentId: null, state: 'idle' }) + }, 2000) + } + } catch (err) { + console.error(`Failed to check agent ${agentId}:`, err) + } + }, INSTALL_CHECK_INTERVAL) + }, + [setAgentResults, setInstallStatus] + ) // Load all agents concurrently async function loadAgents(): Promise { @@ -151,12 +158,17 @@ export function Settings({ } } - // Handle agent installation + // Handle agent installation - opens Terminal async function handleInstall(agentId: string): Promise { - setInstallStatus({ agentId, state: 'installing' }) + // Set waiting state immediately to prevent double-clicks + setInstallStatus({ agentId, state: 'waiting' }) + try { const result = await window.electronAPI.installAgent(agentId) - if (!result.success) { + if (result.success) { + // Terminal opened successfully, start polling for installation + startPolling(agentId) + } else { setInstallStatus({ agentId, state: 'error', error: result.error }) } } catch (err) { @@ -168,6 +180,15 @@ export function Settings({ } } + // Cancel waiting/polling for a specific agent + function handleCancelWaiting(): void { + if (pollingIntervalRef.current) { + clearInterval(pollingIntervalRef.current) + pollingIntervalRef.current = null + } + setInstallStatus({ agentId: null, state: 'idle' }) + } + return ( !open && onClose()}> @@ -227,6 +248,7 @@ export function Settings({ onSelect={handleSelectAgent} installStatus={installStatus} onInstall={handleInstall} + onCancelWaiting={handleCancelWaiting} isHighlighted={id === highlightAgent} /> ) @@ -247,6 +269,7 @@ interface AgentItemProps { onSelect: (agentId: string) => void installStatus: InstallStatus onInstall: (agentId: string) => void + onCancelWaiting: () => void isHighlighted?: boolean // When true, auto-expand and highlight this agent } @@ -259,6 +282,7 @@ function AgentItem({ onSelect, installStatus, onInstall, + onCancelWaiting, isHighlighted }: AgentItemProps): React.ReactElement { const [expanded, setExpanded] = useState(false) @@ -270,8 +294,9 @@ function AgentItem({ } }, [isHighlighted]) - const isInstalling = installStatus.agentId === agentId && installStatus.state === 'installing' + const isWaiting = installStatus.agentId === agentId && installStatus.state === 'waiting' const hasInstallError = installStatus.agentId === agentId && installStatus.state === 'error' + const hasInstallSuccess = installStatus.agentId === agentId && installStatus.state === 'success' const canInstall = ['claude-code', 'opencode', 'codex'].includes(agentId) // Determine status: checking -> setup/selected/ready @@ -283,12 +308,12 @@ function AgentItem({ ? 'selected' : 'ready' - // Auto-expand when installing + // Auto-expand when waiting for install useEffect(() => { - if (isInstalling) { + if (isWaiting) { setExpanded(true) } - }, [isInstalling]) + }, [isWaiting]) // Click row to expand/collapse only const handleRowClick = (): void => { @@ -338,22 +363,22 @@ function AgentItem({ > Use + ) : hasInstallSuccess ? ( + Installed + ) : isWaiting ? ( + + + Waiting... + ) : canInstall ? ( ) : ( Setup required @@ -367,11 +392,33 @@ function AgentItem({

Checking installation status...

) : status === 'setup' ? ( hasInstallError ? ( -

Installation failed: {installStatus.error}

- ) : isInstalling ? ( -

{getStepDescription(installStatus.currentStep, agentId)}

+

+ Failed to open Terminal: {installStatus.error} +

+ ) : hasInstallSuccess ? ( +

Installation detected!

+ ) : isWaiting ? ( +
+

+ Terminal opened. Please complete the installation in the terminal window. +

+

+ This will automatically detect when installation is complete. +

+ +
) : canInstall ? ( -

Click Install to set up {agentName} automatically.

+

+ Click Install to open Terminal with the installation command. +

) : agent?.installHint ? (

To install, run in Terminal:{' '} @@ -408,33 +455,3 @@ function getAgentDescription(agentId: string): string { } return descriptions[agentId] || 'AI coding assistant' } - -function isLastInstallStep(agentId: string, step: InstallStep): boolean { - if (agentId === 'claude-code' || agentId === 'codex') { - return step === 'install-acp' - } - // opencode and others: install-cli is the last step - return step === 'install-cli' -} - -function getStepDescription(step?: InstallStep, agentId?: string): string { - switch (step) { - case 'check-npm': - return 'Checking npm installation...' - case 'install-cli': - if (agentId === 'opencode') { - return 'Installing opencode...' - } - if (agentId === 'codex') { - return 'Installing Codex CLI...' - } - return 'Installing Claude Code CLI...' - case 'install-acp': - if (agentId === 'codex') { - return 'Installing codex-acp...' - } - return 'Installing claude-code-acp...' - default: - return 'Preparing installation...' - } -} diff --git a/tests/unit/main/utils/agent-install.test.ts b/tests/unit/main/utils/agent-install.test.ts new file mode 100644 index 000000000..44f17c5ba --- /dev/null +++ b/tests/unit/main/utils/agent-install.test.ts @@ -0,0 +1,319 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import type { ChildProcess } from 'child_process' +import type { EventEmitter } from 'events' + +// Mock child_process +vi.mock('child_process', () => ({ + spawn: vi.fn() +})) + +// Mock os module +vi.mock('os', () => ({ + platform: vi.fn().mockReturnValue('darwin') +})) + +import { spawn } from 'child_process' +import { platform } from 'os' +import { + installAgent, + openTerminalWithCommand, + INSTALL_COMMANDS +} from '../../../../src/main/utils/agent-install' + +const mockSpawn = vi.mocked(spawn) +const mockPlatform = vi.mocked(platform) + +// Helper to create a mock child process +function createMockProcess(): { + proc: Partial + emit: (event: string, ...args: unknown[]) => void +} { + const listeners: Record void)[]> = {} + + const proc: Partial = { + on: vi.fn((event: string, callback: (...args: unknown[]) => void) => { + if (!listeners[event]) { + listeners[event] = [] + } + listeners[event].push(callback) + return proc as ChildProcess + }), + unref: vi.fn() + } + + const emit = (event: string, ...args: unknown[]): void => { + const callbacks = listeners[event] || [] + callbacks.forEach((cb) => cb(...args)) + } + + return { proc, emit } +} + +describe('agent-install', () => { + beforeEach(() => { + vi.clearAllMocks() + mockPlatform.mockReturnValue('darwin') + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('INSTALL_COMMANDS', () => { + it('should have install commands for supported agents', () => { + // Claude Code: CLI + ACP + expect(INSTALL_COMMANDS['claude-code']).toBe( + 'curl -fsSL https://claude.ai/install.sh | bash && npm install -g @zed-industries/claude-code-acp' + ) + // OpenCode: official install script + expect(INSTALL_COMMANDS['opencode']).toBe('curl -fsSL https://opencode.ai/install | bash') + // Codex: CLI + ACP in one command + expect(INSTALL_COMMANDS['codex']).toBe( + 'npm install -g @openai/codex @zed-industries/codex-acp' + ) + // Gemini: single package + expect(INSTALL_COMMANDS['gemini']).toBe('npm install -g @google/gemini-cli') + }) + }) + + describe('openTerminalWithCommand', () => { + describe('macOS', () => { + beforeEach(() => { + mockPlatform.mockReturnValue('darwin') + }) + + it('should use osascript to open Terminal.app', async () => { + const { proc, emit } = createMockProcess() + mockSpawn.mockReturnValue(proc as ChildProcess) + + const promise = openTerminalWithCommand('echo hello') + + // Simulate successful spawn + emit('close', 0) + + const result = await promise + + expect(mockSpawn).toHaveBeenCalledWith('osascript', ['-e', expect.any(String)]) + expect(result.success).toBe(true) + }) + + it('should escape double quotes in command', async () => { + const { proc, emit } = createMockProcess() + mockSpawn.mockReturnValue(proc as ChildProcess) + + const promise = openTerminalWithCommand('echo "hello world"') + emit('close', 0) + await promise + + const osascriptArg = mockSpawn.mock.calls[0][1][1] + expect(osascriptArg).toContain('echo \\"hello world\\"') + }) + + it('should return error when osascript fails', async () => { + const { proc, emit } = createMockProcess() + mockSpawn.mockReturnValue(proc as ChildProcess) + + const promise = openTerminalWithCommand('echo hello') + emit('close', 1) + + const result = await promise + + expect(result.success).toBe(false) + expect(result.error).toBe('osascript exited with code 1') + }) + + it('should handle spawn error', async () => { + const { proc, emit } = createMockProcess() + mockSpawn.mockReturnValue(proc as ChildProcess) + + const promise = openTerminalWithCommand('echo hello') + emit('error', new Error('spawn ENOENT')) + + const result = await promise + + expect(result.success).toBe(false) + expect(result.error).toBe('spawn ENOENT') + }) + }) + + describe('Windows', () => { + beforeEach(() => { + mockPlatform.mockReturnValue('win32') + }) + + it('should use cmd.exe to open terminal', async () => { + const { proc, emit } = createMockProcess() + mockSpawn.mockReturnValue(proc as ChildProcess) + + const promise = openTerminalWithCommand('npm install') + emit('close', 0) + + const result = await promise + + expect(mockSpawn).toHaveBeenCalledWith( + 'cmd.exe', + ['/c', 'start', 'cmd.exe', '/K', 'npm install'], + { shell: true } + ) + expect(result.success).toBe(true) + }) + + it('should return error when cmd fails', async () => { + const { proc, emit } = createMockProcess() + mockSpawn.mockReturnValue(proc as ChildProcess) + + const promise = openTerminalWithCommand('npm install') + emit('close', 1) + + const result = await promise + + expect(result.success).toBe(false) + expect(result.error).toBe('cmd exited with code 1') + }) + }) + + describe('Linux', () => { + beforeEach(() => { + mockPlatform.mockReturnValue('linux') + vi.useFakeTimers() + }) + + it('should try gnome-terminal first', async () => { + const { proc } = createMockProcess() + mockSpawn.mockReturnValue(proc as ChildProcess) + + const promise = openTerminalWithCommand('npm install') + + // Fast-forward timers to trigger the success timeout + vi.advanceTimersByTime(100) + + const result = await promise + + expect(mockSpawn).toHaveBeenCalledWith( + 'gnome-terminal', + ['--', 'bash', '-c', 'npm install; exec bash'], + { detached: true, stdio: 'ignore' } + ) + expect(result.success).toBe(true) + }) + + it('should try next terminal if gnome-terminal fails', async () => { + let callCount = 0 + mockSpawn.mockImplementation((cmd) => { + callCount++ + const { proc, emit } = createMockProcess() + + // First call (gnome-terminal) fails, second (konsole) succeeds + if (cmd === 'gnome-terminal') { + setTimeout(() => emit('error', new Error('ENOENT')), 0) + } + + return proc as ChildProcess + }) + + const promise = openTerminalWithCommand('npm install') + + // Run pending timers to trigger error and retry + await vi.advanceTimersByTimeAsync(0) + // Advance to trigger success timeout + vi.advanceTimersByTime(100) + + const result = await promise + + // Should have tried gnome-terminal first, then konsole + expect(callCount).toBeGreaterThanOrEqual(2) + expect(result.success).toBe(true) + }) + + it('should return error when no terminal is found', async () => { + mockSpawn.mockImplementation(() => { + const { proc, emit } = createMockProcess() + setTimeout(() => emit('error', new Error('ENOENT')), 0) + return proc as ChildProcess + }) + + const promise = openTerminalWithCommand('npm install') + + // Run all timers to exhaust terminal options + await vi.runAllTimersAsync() + + const result = await promise + + expect(result.success).toBe(false) + expect(result.error).toBe('No supported terminal emulator found') + }) + }) + }) + + describe('installAgent', () => { + beforeEach(() => { + mockPlatform.mockReturnValue('darwin') + }) + + it('should open terminal with correct command for claude-code', async () => { + const { proc, emit } = createMockProcess() + mockSpawn.mockReturnValue(proc as ChildProcess) + + const promise = installAgent('claude-code') + emit('close', 0) + + const result = await promise + + expect(result.success).toBe(true) + const osascriptArg = mockSpawn.mock.calls[0][1][1] + // Should install both CLI and ACP + expect(osascriptArg).toContain('curl -fsSL https://claude.ai/install.sh | bash') + expect(osascriptArg).toContain('npm install -g @zed-industries/claude-code-acp') + }) + + it('should open terminal with correct command for opencode', async () => { + const { proc, emit } = createMockProcess() + mockSpawn.mockReturnValue(proc as ChildProcess) + + const promise = installAgent('opencode') + emit('close', 0) + + const result = await promise + + expect(result.success).toBe(true) + const osascriptArg = mockSpawn.mock.calls[0][1][1] + expect(osascriptArg).toContain('curl -fsSL https://opencode.ai/install | bash') + }) + + it('should open terminal with correct command for codex', async () => { + const { proc, emit } = createMockProcess() + mockSpawn.mockReturnValue(proc as ChildProcess) + + const promise = installAgent('codex') + emit('close', 0) + + const result = await promise + + expect(result.success).toBe(true) + const osascriptArg = mockSpawn.mock.calls[0][1][1] + // Should install both CLI and ACP in one command + expect(osascriptArg).toContain('npm install -g @openai/codex @zed-industries/codex-acp') + }) + + it('should return error for unsupported agent', async () => { + const result = await installAgent('unsupported-agent') + + expect(result.success).toBe(false) + expect(result.error).toBe('Installation not supported for: unsupported-agent') + expect(mockSpawn).not.toHaveBeenCalled() + }) + + it('should return error when terminal fails to open', async () => { + const { proc, emit } = createMockProcess() + mockSpawn.mockReturnValue(proc as ChildProcess) + + const promise = installAgent('claude-code') + emit('error', new Error('spawn failed')) + + const result = await promise + + expect(result.success).toBe(false) + expect(result.error).toBe('spawn failed') + }) + }) +})