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 <noreply@anthropic.com>
This commit is contained in:
yushen
2026-01-16 13:15:55 +08:00
parent a404575fc5
commit ea959e577b
5 changed files with 547 additions and 440 deletions

View File

@@ -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))
}

View File

@@ -27,7 +27,7 @@ export interface AgentCheckResult {
// Install hints for each agent
const INSTALL_HINTS: Record<string, string> = {
'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<AgentCheckResult | nu
// Find primary command result from already-checked commands (avoid duplicate check)
const primaryResult = commandChecks.find((c) => 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 }))

View File

@@ -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<string, string> = {
// 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<InstallResult> {
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<InstallResult> {
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<InstallResult> {
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<InstallResult> {
const { agentId } = options
export async function installAgent(agentId: string): Promise<InstallResult> {
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 }
}

View File

@@ -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<ReturnType<typeof setInterval> | 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<void> {
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<void> {
@@ -151,12 +158,17 @@ export function Settings({
}
}
// Handle agent installation
// Handle agent installation - opens Terminal
async function handleInstall(agentId: string): Promise<void> {
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 (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="sm:max-w-5xl h-[85vh] max-h-[85vh] overflow-y-auto content-start">
@@ -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
</button>
) : hasInstallSuccess ? (
<span className="text-xs text-green-600">Installed</span>
) : isWaiting ? (
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
Waiting...
</span>
) : canInstall ? (
<button
onClick={(e) => {
e.stopPropagation()
onInstall(agentId)
}}
disabled={isInstalling}
className={cn(
'text-xs px-2 py-0.5 rounded transition-colors flex items-center gap-1',
isInstalling
? 'bg-muted text-muted-foreground cursor-not-allowed'
: 'bg-primary/10 text-primary hover:bg-primary/20'
)}
className="text-xs px-2 py-0.5 rounded transition-colors bg-primary/10 text-primary hover:bg-primary/20"
>
{isInstalling && <Loader2 className="h-3 w-3 animate-spin" />}
{isInstalling ? 'Installing...' : 'Install'}
Install
</button>
) : (
<span className="text-xs text-muted-foreground">Setup required</span>
@@ -367,11 +392,33 @@ function AgentItem({
<p className="text-xs">Checking installation status...</p>
) : status === 'setup' ? (
hasInstallError ? (
<p className="text-xs text-destructive">Installation failed: {installStatus.error}</p>
) : isInstalling ? (
<p className="text-xs">{getStepDescription(installStatus.currentStep, agentId)}</p>
<p className="text-xs text-destructive">
Failed to open Terminal: {installStatus.error}
</p>
) : hasInstallSuccess ? (
<p className="text-xs text-green-600">Installation detected!</p>
) : isWaiting ? (
<div className="space-y-2">
<p className="text-xs">
Terminal opened. Please complete the installation in the terminal window.
</p>
<p className="text-xs text-muted-foreground/70">
This will automatically detect when installation is complete.
</p>
<button
onClick={(e) => {
e.stopPropagation()
onCancelWaiting()
}}
className="text-xs px-2 py-0.5 rounded transition-colors bg-muted text-muted-foreground hover:bg-muted/80"
>
Cancel
</button>
</div>
) : canInstall ? (
<p className="text-xs">Click Install to set up {agentName} automatically.</p>
<p className="text-xs">
Click Install to open Terminal with the installation command.
</p>
) : agent?.installHint ? (
<p className="text-xs">
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...'
}
}

View File

@@ -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<ChildProcess>
emit: (event: string, ...args: unknown[]) => void
} {
const listeners: Record<string, ((...args: unknown[]) => void)[]> = {}
const proc: Partial<ChildProcess> = {
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')
})
})
})