mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-05 13:29:44 +02:00
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:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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...'
|
||||
}
|
||||
}
|
||||
|
||||
319
tests/unit/main/utils/agent-install.test.ts
Normal file
319
tests/unit/main/utils/agent-install.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user