diff --git a/check_types.mjs b/check_types.mjs new file mode 100644 index 0000000..c52fd6f --- /dev/null +++ b/check_types.mjs @@ -0,0 +1,25 @@ +// Check if Object.fromEntries result is assignable to Partial> + +// According to TypeScript: +// Partial> expands to: +// { claude?: boolean; codex?: boolean; opencode?: boolean } + +// Object.fromEntries returns: { [k: string]: any } +// In the context of: +// const results: Array<[SpawnAgentCli, boolean]> = [['claude', true], ...] +// Object.fromEntries(results) is typed as: { [k: string]: boolean } + +// The question: is { [k: string]: boolean } assignable to +// { claude?: boolean; codex?: boolean; opencode?: boolean }? + +// Answer: YES, because: +// 1. { [k: string]: boolean } is an index signature that matches ANY string key +// 2. { claude?: boolean; codex?: boolean; opencode?: boolean } only uses specific keys +// 3. Any value with an index signature { [k: string]: T } satisfies a type with specific optional properties + +console.log("Type check analysis:"); +console.log("Object.fromEntries(results) type: { [k: string]: boolean }"); +console.log("setCliAvailability expects: Partial>"); +console.log("Which expands to: { claude?: boolean; codex?: boolean; opencode?: boolean }"); +console.log(""); +console.log("TypeScript assignability: YES - index signature types are assignable to property-specific types"); diff --git a/src/main/broker.test.ts b/src/main/broker.test.ts index 5d83b63..215b714 100644 --- a/src/main/broker.test.ts +++ b/src/main/broker.test.ts @@ -142,7 +142,7 @@ vi.mock('./burn', () => ({ getPearBurnAgentKey: vi.fn((projectId: string, name: string) => `${projectId}:${name}`) })) -import { BrokerManager, resolveAgentRelayMcpCommand } from './broker' +import { BrokerManager, isCommandAvailableWithAugmentedPath, resolveAgentRelayMcpCommand } from './broker' const PROJECT_ID = 'project-1' const originalMcpCommand = process.env.AGENT_RELAY_MCP_COMMAND @@ -150,6 +150,7 @@ const originalResourcesPathDescriptor = Object.getOwnPropertyDescriptor(process, const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform') const originalPublicEnv = process.env.PUBLIC const originalProgramDataEnv = process.env.ProgramData +const originalPathEnv = process.env.PATH function setProcessPlatform(platform: NodeJS.Platform): void { Object.defineProperty(process, 'platform', { @@ -1159,3 +1160,28 @@ describe('BrokerManager spawnAgent CLI preflight', () => { await manager.shutdown() }) }) + +describe('isCommandAvailableWithAugmentedPath', () => { + let tempDir: string | null = null + + afterEach(async () => { + process.env.PATH = originalPathEnv + if (tempDir) await rm(tempDir, { recursive: true, force: true }) + tempDir = null + }) + + it('resolves commands without mutating PATH', async () => { + tempDir = await mkdtemp(join(tmpdir(), 'pear-cli-path-')) + const toolPath = join(tempDir, 'opencode') + await writeFile(toolPath, '#!/bin/sh\nexit 0\n') + await chmod(toolPath, 0o755) + process.env.PATH = tempDir + + expect(isCommandAvailableWithAugmentedPath('opencode')).toBe(true) + expect(process.env.PATH).toBe(tempDir) + }) + + it('returns false for blank commands', () => { + expect(isCommandAvailableWithAugmentedPath(' ')).toBe(false) + }) +}) diff --git a/src/main/broker.ts b/src/main/broker.ts index 4c584c7..7796039 100644 --- a/src/main/broker.ts +++ b/src/main/broker.ts @@ -81,7 +81,7 @@ function augmentedPath(): string { return Array.from(entries).join(delimiter) } -function resolveCommandWithAugmentedPath(command: string): string | undefined { +export function resolveCommandWithAugmentedPath(command: string): string | undefined { const resolved = resolveCommandOnPath(command) if (resolved) return resolved @@ -96,6 +96,15 @@ function resolveCommandWithAugmentedPath(command: string): string | undefined { return undefined } +export function isCommandAvailableWithAugmentedPath(command: string): boolean { + const trimmed = command.trim() + if (!trimmed) return false + return Boolean(resolveCommandOnPath(trimmed, { + ...process.env, + PATH: augmentedPath() + })) +} + function executableCliPath(input: SpawnPtyInput): string { return isAbsolute(input.cli) ? input.cli diff --git a/src/main/ipc-handlers.test.ts b/src/main/ipc-handlers.test.ts index 4483d74..6d13b82 100644 --- a/src/main/ipc-handlers.test.ts +++ b/src/main/ipc-handlers.test.ts @@ -46,6 +46,7 @@ const mock = vi.hoisted(() => { detachCloudSandbox: vi.fn(), onBrokerEvent: vi.fn() }, + isCommandAvailableWithAugmentedPath: vi.fn(), integrationsManager: { notifyAgentState: vi.fn(async () => undefined), initialSpawnInstructions: vi.fn(), @@ -101,7 +102,8 @@ vi.mock('./store', () => ({ })) vi.mock('./broker', () => ({ - brokerManager: mock.brokerManager + brokerManager: mock.brokerManager, + isCommandAvailableWithAugmentedPath: mock.isCommandAvailableWithAugmentedPath })) vi.mock('./git', () => ({})) @@ -197,3 +199,34 @@ describe('registerIpcHandlers broker:spawn-agent', () => { expect(() => structuredClone(result)).not.toThrow() }) }) + +describe('registerIpcHandlers broker:check-cli-available', () => { + beforeEach(() => { + mock.handlers.clear() + mock.ipcMain.handle.mockClear() + mock.ipcMain.on.mockClear() + mock.isCommandAvailableWithAugmentedPath.mockReset() + registerIpcHandlers() + }) + + it('returns whether the requested CLI can be resolved', () => { + const handler = mock.handlers.get('broker:check-cli-available') + expect(handler).toBeTypeOf('function') + mock.isCommandAvailableWithAugmentedPath.mockReturnValueOnce(true) + + const result = handler?.({}, 'opencode') + + expect(result).toBe(true) + expect(mock.isCommandAvailableWithAugmentedPath).toHaveBeenCalledWith('opencode') + }) + + it('rejects non-string CLI values', () => { + const handler = mock.handlers.get('broker:check-cli-available') + expect(handler).toBeTypeOf('function') + + const result = handler?.({}, null) + + expect(result).toBe(false) + expect(mock.isCommandAvailableWithAugmentedPath).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 941d5b0..7d48b85 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -17,7 +17,7 @@ import { addProjectIntegration, removeProjectIntegration } from './store' -import { brokerManager } from './broker' +import { brokerManager, isCommandAvailableWithAugmentedPath } from './broker' import * as git from './git' import * as filesystem from './filesystem' import * as auth from './auth' @@ -351,6 +351,10 @@ export function registerIpcHandlers(): void { return brokerManager.listAgents(projectId) }) + ipcMain.handle('broker:check-cli-available', (_, cli: string) => { + return typeof cli === 'string' && isCommandAvailableWithAugmentedPath(cli) + }) + ipcMain.handle('broker:list-details', async () => { return brokerManager.listBrokerDetails() }) diff --git a/src/preload/index.ts b/src/preload/index.ts index f5c8985..af878ca 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -275,6 +275,8 @@ const api = { invoke('broker:release-agent', projectId, name), listAgents: (projectId?: string) => invoke('broker:list-agents', projectId), + checkCliAvailable: (cli: string) => + invoke('broker:check-cli-available', cli), listDetails: () => invoke('broker:list-details'), listEvents: () => invoke('broker:list-events'), shutdown: () => invoke('broker:shutdown'), diff --git a/src/renderer/src/components/sidebar/SpawnAgentDialog.tsx b/src/renderer/src/components/sidebar/SpawnAgentDialog.tsx index 0e99049..29877dc 100644 --- a/src/renderer/src/components/sidebar/SpawnAgentDialog.tsx +++ b/src/renderer/src/components/sidebar/SpawnAgentDialog.tsx @@ -1,15 +1,16 @@ import type React from 'react' import { useCallback, useEffect, useRef, useState } from 'react' import { Loader2, X } from 'lucide-react' -import { ClaudeIcon, CodexIcon } from '@/components/common/AgentIcons' -import { listProjectPersonas, spawnProjectAgent, spawnProjectPersona, type SpawnAgentCli } from '@/lib/spawn-agent' -import type { WorkforcePersona } from '@/lib/ipc' +import { ClaudeIcon, CodexIcon, OpenCodeIcon } from '@/components/common/AgentIcons' +import { SPAWN_AGENT_CLI_INSTALL_COMMANDS, listProjectPersonas, spawnProjectAgent, spawnProjectPersona, type SpawnAgentCli } from '@/lib/spawn-agent' +import { pear, type WorkforcePersona } from '@/lib/ipc' import { useProjectStore, type ProjectRoot } from '@/stores/project-store' import { useUIStore } from '@/stores/ui-store' const AGENT_OPTIONS: Array<{ cli: SpawnAgentCli; label: string; Icon: typeof ClaudeIcon }> = [ { cli: 'claude', label: 'Claude', Icon: ClaudeIcon }, - { cli: 'codex', label: 'Codex', Icon: CodexIcon } + { cli: 'codex', label: 'Codex', Icon: CodexIcon }, + { cli: 'opencode', label: 'OpenCode', Icon: OpenCodeIcon } ] export function SpawnAgentDialog(): React.ReactNode { @@ -20,6 +21,7 @@ export function SpawnAgentDialog(): React.ReactNode { const [selectedPersonaId, setSelectedPersonaId] = useState('') const [customName, setCustomName] = useState('') const [error, setError] = useState(null) + const [cliAvailability, setCliAvailability] = useState>>({}) const [selectedRootId, setSelectedRootId] = useState(null) const project = useProjectStore((s) => s.getActiveProject()) const defaultRoot = useProjectStore((s) => s.getActiveRoot()) @@ -45,6 +47,18 @@ export function SpawnAgentDialog(): React.ReactNode { } }, [project, root?.id, selectedRootId]) + useEffect(() => { + let cancelled = false + const clis: SpawnAgentCli[] = ['claude', 'codex', 'opencode'] + void Promise.all(clis.map(async (cli) => { + const available = await pear.broker.checkCliAvailable(cli).catch(() => false) + return [cli, available] as const + })).then((results) => { + if (!cancelled) setCliAvailability(Object.fromEntries(results)) + }) + return () => { cancelled = true } + }, []) + useEffect(() => { let cancelled = false @@ -207,16 +221,20 @@ export function SpawnAgentDialog(): React.ReactNode { className="h-9 w-full rounded-md border border-[var(--pear-border-subtle)] bg-[var(--pear-bg)] px-3 text-sm text-[var(--pear-text)] outline-none placeholder:text-[var(--pear-text-faint)] focus:border-[var(--pear-accent-dim)] disabled:opacity-50" /> -
+
{AGENT_OPTIONS.map(({ cli, label, Icon }) => ( - +
+ {([ + { cli: 'claude', label: 'Claude', Icon: ClaudeIcon }, + { cli: 'codex', label: 'Codex', Icon: CodexIcon }, + { cli: 'opencode', label: 'OpenCode', Icon: OpenCodeIcon } + ] as const).map(({ cli, label, Icon }) => ( + + ))}
) : (