refactor: decompose agent-detector into focused modules

- Extract agent-definitions.ts (221 lines) with AGENT_DEFINITIONS and types
- Extract path-prober.ts (489 lines) with platform-specific binary detection
- Reduce agent-detector.ts from 865 to 283 lines (67% reduction)
- Add helper functions: getAgentDefinition, getAgentIds, getVisibleAgentDefinitions
- Maintain API compatibility via re-exports
- Add 49 new tests (26 for definitions, 23 for path-prober)
This commit is contained in:
Raza Rauf
2026-01-29 20:01:56 +05:00
parent 9b9cf1f74c
commit e95ef0c369
6 changed files with 1465 additions and 635 deletions

View File

@@ -0,0 +1,253 @@
/**
* Tests for agent-definitions.ts
*
* Tests the agent definition data structures and helper functions.
*/
import { describe, it, expect } from 'vitest';
import {
AGENT_DEFINITIONS,
getAgentDefinition,
getAgentIds,
getVisibleAgentDefinitions,
type AgentDefinition,
type AgentConfigOption,
} from '../../main/agent-definitions';
describe('agent-definitions', () => {
describe('AGENT_DEFINITIONS', () => {
it('should contain all expected agents', () => {
const agentIds = AGENT_DEFINITIONS.map((def) => def.id);
expect(agentIds).toContain('terminal');
expect(agentIds).toContain('claude-code');
expect(agentIds).toContain('codex');
expect(agentIds).toContain('opencode');
expect(agentIds).toContain('gemini-cli');
expect(agentIds).toContain('qwen3-coder');
expect(agentIds).toContain('aider');
});
it('should have required properties on all definitions', () => {
for (const def of AGENT_DEFINITIONS) {
expect(def.id).toBeDefined();
expect(def.name).toBeDefined();
expect(def.binaryName).toBeDefined();
expect(def.command).toBeDefined();
expect(def.args).toBeDefined();
expect(Array.isArray(def.args)).toBe(true);
}
});
it('should have terminal as a hidden agent', () => {
const terminal = AGENT_DEFINITIONS.find((def) => def.id === 'terminal');
expect(terminal?.hidden).toBe(true);
});
it('should have claude-code with correct base args', () => {
const claudeCode = AGENT_DEFINITIONS.find((def) => def.id === 'claude-code');
expect(claudeCode).toBeDefined();
expect(claudeCode?.args).toContain('--print');
expect(claudeCode?.args).toContain('--verbose');
expect(claudeCode?.args).toContain('--output-format');
expect(claudeCode?.args).toContain('stream-json');
expect(claudeCode?.args).toContain('--dangerously-skip-permissions');
});
it('should have codex with batch mode configuration', () => {
const codex = AGENT_DEFINITIONS.find((def) => def.id === 'codex');
expect(codex).toBeDefined();
expect(codex?.batchModePrefix).toEqual(['exec']);
expect(codex?.batchModeArgs).toContain('--dangerously-bypass-approvals-and-sandbox');
expect(codex?.jsonOutputArgs).toEqual(['--json']);
});
it('should have opencode with batch mode configuration', () => {
const opencode = AGENT_DEFINITIONS.find((def) => def.id === 'opencode');
expect(opencode).toBeDefined();
expect(opencode?.batchModePrefix).toEqual(['run']);
expect(opencode?.jsonOutputArgs).toEqual(['--format', 'json']);
expect(opencode?.noPromptSeparator).toBe(true);
});
it('should have opencode with default env vars for YOLO mode', () => {
const opencode = AGENT_DEFINITIONS.find((def) => def.id === 'opencode');
expect(opencode?.defaultEnvVars).toBeDefined();
expect(opencode?.defaultEnvVars?.OPENCODE_CONFIG_CONTENT).toContain('external_directory');
});
});
describe('getAgentDefinition', () => {
it('should return definition for valid agent ID', () => {
const claudeCode = getAgentDefinition('claude-code');
expect(claudeCode).toBeDefined();
expect(claudeCode?.id).toBe('claude-code');
expect(claudeCode?.name).toBe('Claude Code');
});
it('should return undefined for invalid agent ID', () => {
const invalid = getAgentDefinition('non-existent-agent');
expect(invalid).toBeUndefined();
});
it('should return definition for all known agents', () => {
const knownAgents = ['terminal', 'claude-code', 'codex', 'opencode', 'gemini-cli', 'aider'];
for (const agentId of knownAgents) {
const def = getAgentDefinition(agentId);
expect(def).toBeDefined();
expect(def?.id).toBe(agentId);
}
});
});
describe('getAgentIds', () => {
it('should return array of all agent IDs', () => {
const ids = getAgentIds();
expect(Array.isArray(ids)).toBe(true);
expect(ids.length).toBeGreaterThan(0);
expect(ids).toContain('claude-code');
expect(ids).toContain('terminal');
});
it('should match AGENT_DEFINITIONS length', () => {
const ids = getAgentIds();
expect(ids.length).toBe(AGENT_DEFINITIONS.length);
});
});
describe('getVisibleAgentDefinitions', () => {
it('should not include hidden agents', () => {
const visible = getVisibleAgentDefinitions();
const visibleIds = visible.map((def) => def.id);
// Terminal should be hidden
expect(visibleIds).not.toContain('terminal');
});
it('should include visible agents', () => {
const visible = getVisibleAgentDefinitions();
const visibleIds = visible.map((def) => def.id);
expect(visibleIds).toContain('claude-code');
expect(visibleIds).toContain('codex');
expect(visibleIds).toContain('opencode');
});
it('should return fewer items than AGENT_DEFINITIONS', () => {
const visible = getVisibleAgentDefinitions();
expect(visible.length).toBeLessThan(AGENT_DEFINITIONS.length);
});
});
describe('Agent argument builders', () => {
it('should have resumeArgs function for claude-code', () => {
const claudeCode = getAgentDefinition('claude-code');
expect(claudeCode?.resumeArgs).toBeDefined();
expect(typeof claudeCode?.resumeArgs).toBe('function');
const args = claudeCode?.resumeArgs?.('test-session-123');
expect(args).toEqual(['--resume', 'test-session-123']);
});
it('should have resumeArgs function for codex', () => {
const codex = getAgentDefinition('codex');
expect(codex?.resumeArgs).toBeDefined();
const args = codex?.resumeArgs?.('thread-456');
expect(args).toEqual(['resume', 'thread-456']);
});
it('should have resumeArgs function for opencode', () => {
const opencode = getAgentDefinition('opencode');
expect(opencode?.resumeArgs).toBeDefined();
const args = opencode?.resumeArgs?.('session-789');
expect(args).toEqual(['--session', 'session-789']);
});
it('should have modelArgs function for opencode', () => {
const opencode = getAgentDefinition('opencode');
expect(opencode?.modelArgs).toBeDefined();
const args = opencode?.modelArgs?.('ollama/qwen3:8b');
expect(args).toEqual(['--model', 'ollama/qwen3:8b']);
});
it('should have workingDirArgs function for codex', () => {
const codex = getAgentDefinition('codex');
expect(codex?.workingDirArgs).toBeDefined();
const args = codex?.workingDirArgs?.('/path/to/project');
expect(args).toEqual(['-C', '/path/to/project']);
});
it('should have imageArgs function for codex', () => {
const codex = getAgentDefinition('codex');
expect(codex?.imageArgs).toBeDefined();
const args = codex?.imageArgs?.('/path/to/image.png');
expect(args).toEqual(['-i', '/path/to/image.png']);
});
it('should have imageArgs function for opencode', () => {
const opencode = getAgentDefinition('opencode');
expect(opencode?.imageArgs).toBeDefined();
const args = opencode?.imageArgs?.('/path/to/image.png');
expect(args).toEqual(['-f', '/path/to/image.png']);
});
});
describe('Agent config options', () => {
it('should have configOptions for codex', () => {
const codex = getAgentDefinition('codex');
expect(codex?.configOptions).toBeDefined();
expect(Array.isArray(codex?.configOptions)).toBe(true);
const contextWindowOption = codex?.configOptions?.find((opt) => opt.key === 'contextWindow');
expect(contextWindowOption).toBeDefined();
expect(contextWindowOption?.type).toBe('number');
expect(contextWindowOption?.default).toBe(400000);
});
it('should have configOptions for opencode', () => {
const opencode = getAgentDefinition('opencode');
expect(opencode?.configOptions).toBeDefined();
const modelOption = opencode?.configOptions?.find((opt) => opt.key === 'model');
expect(modelOption).toBeDefined();
expect(modelOption?.type).toBe('text');
expect(modelOption?.default).toBe('');
// Test argBuilder
expect(modelOption?.argBuilder).toBeDefined();
expect(modelOption?.argBuilder?.('ollama/qwen3:8b')).toEqual(['--model', 'ollama/qwen3:8b']);
expect(modelOption?.argBuilder?.('')).toEqual([]);
expect(modelOption?.argBuilder?.(' ')).toEqual([]);
});
});
describe('Type definitions', () => {
it('should export AgentDefinition type', () => {
const def: AgentDefinition = {
id: 'test',
name: 'Test Agent',
binaryName: 'test',
command: 'test',
args: [],
};
expect(def.id).toBe('test');
});
it('should export AgentConfigOption type', () => {
const option: AgentConfigOption = {
key: 'testKey',
type: 'text',
label: 'Test Label',
description: 'Test description',
default: 'default value',
};
expect(option.key).toBe('testKey');
});
});
});

View File

@@ -499,7 +499,7 @@ describe('agent-detector', () => {
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('not executable'),
'AgentDetector'
'PathProber'
);
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });

View File

@@ -0,0 +1,452 @@
/**
* Tests for path-prober.ts
*
* Tests the platform-specific binary detection logic.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'fs';
// Mock dependencies before importing the module
vi.mock('../../main/utils/execFile', () => ({
execFileNoThrow: vi.fn(),
}));
vi.mock('../../main/utils/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
vi.mock('../../shared/pathUtils', () => ({
expandTilde: vi.fn((p: string) => p.replace(/^~/, '/Users/testuser')),
detectNodeVersionManagerBinPaths: vi.fn(() => []),
}));
// Import after mocking
import {
getExpandedEnv,
checkCustomPath,
checkBinaryExists,
probeWindowsPaths,
probeUnixPaths,
type BinaryDetectionResult,
} from '../../main/path-prober';
import { execFileNoThrow } from '../../main/utils/execFile';
import { logger } from '../../main/utils/logger';
describe('path-prober', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('getExpandedEnv', () => {
it('should return environment with PATH', () => {
const env = getExpandedEnv();
expect(env.PATH).toBeDefined();
expect(typeof env.PATH).toBe('string');
});
it('should include common Unix paths on non-Windows', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
try {
const env = getExpandedEnv();
expect(env.PATH).toContain('/opt/homebrew/bin');
expect(env.PATH).toContain('/usr/local/bin');
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
it('should preserve existing PATH entries', () => {
const originalPath = process.env.PATH;
const testPath = '/test/custom/path';
process.env.PATH = testPath;
try {
const env = getExpandedEnv();
expect(env.PATH).toContain(testPath);
} finally {
process.env.PATH = originalPath;
}
});
});
describe('checkCustomPath', () => {
let statMock: ReturnType<typeof vi.spyOn>;
let accessMock: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
statMock = vi.spyOn(fs.promises, 'stat');
accessMock = vi.spyOn(fs.promises, 'access');
});
afterEach(() => {
statMock.mockRestore();
accessMock.mockRestore();
});
it('should return exists: true for valid executable path on Unix', async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
try {
statMock.mockResolvedValue({ isFile: () => true } as fs.Stats);
accessMock.mockResolvedValue(undefined);
const result = await checkCustomPath('/usr/local/bin/claude');
expect(result.exists).toBe(true);
expect(result.path).toBe('/usr/local/bin/claude');
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
it('should return exists: false for non-executable file on Unix', async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
try {
statMock.mockResolvedValue({ isFile: () => true } as fs.Stats);
accessMock.mockRejectedValue(new Error('EACCES'));
const result = await checkCustomPath('/path/to/non-executable');
expect(result.exists).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('not executable'),
'PathProber'
);
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
it('should return exists: false for non-existent path', async () => {
statMock.mockRejectedValue(new Error('ENOENT'));
const result = await checkCustomPath('/non/existent/path');
expect(result.exists).toBe(false);
});
it('should expand tilde in path', async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
try {
statMock.mockResolvedValue({ isFile: () => true } as fs.Stats);
accessMock.mockResolvedValue(undefined);
const result = await checkCustomPath('~/.local/bin/claude');
expect(result.exists).toBe(true);
expect(result.path).toBe('/Users/testuser/.local/bin/claude');
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
it('should try .exe extension on Windows', async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
try {
// First call (exact path) returns false, second call (.exe) returns true
statMock
.mockRejectedValueOnce(new Error('ENOENT'))
.mockResolvedValueOnce({ isFile: () => true } as fs.Stats);
const result = await checkCustomPath('C:\\custom\\claude');
expect(result.exists).toBe(true);
expect(result.path).toBe('C:\\custom\\claude.exe');
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
it('should try .cmd extension on Windows if .exe not found', async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
try {
// First call (exact), second (.exe) return false, third (.cmd) returns true
statMock
.mockRejectedValueOnce(new Error('ENOENT'))
.mockRejectedValueOnce(new Error('ENOENT'))
.mockResolvedValueOnce({ isFile: () => true } as fs.Stats);
const result = await checkCustomPath('C:\\custom\\claude');
expect(result.exists).toBe(true);
expect(result.path).toBe('C:\\custom\\claude.cmd');
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
it('should skip executable check on Windows', async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
try {
statMock.mockResolvedValue({ isFile: () => true } as fs.Stats);
// Don't mock access - it shouldn't be called for X_OK on Windows
const result = await checkCustomPath('C:\\custom\\claude.exe');
expect(result.exists).toBe(true);
// access should not be called with X_OK on Windows
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
});
describe('probeWindowsPaths', () => {
let accessMock: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
accessMock = vi.spyOn(fs.promises, 'access');
});
afterEach(() => {
accessMock.mockRestore();
});
it('should return null for unknown binary', async () => {
accessMock.mockRejectedValue(new Error('ENOENT'));
const result = await probeWindowsPaths('unknown-binary');
expect(result).toBeNull();
});
it('should probe known paths for claude binary', async () => {
// All paths fail - binary not found
accessMock.mockRejectedValue(new Error('ENOENT'));
const result = await probeWindowsPaths('claude');
// Should return null since all probes fail
expect(result).toBeNull();
// Should have tried multiple paths
expect(accessMock).toHaveBeenCalled();
});
});
describe('probeUnixPaths', () => {
let accessMock: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
accessMock = vi.spyOn(fs.promises, 'access');
});
afterEach(() => {
accessMock.mockRestore();
});
it('should return null for unknown binary', async () => {
accessMock.mockRejectedValue(new Error('ENOENT'));
const result = await probeUnixPaths('unknown-binary');
expect(result).toBeNull();
});
it('should probe known paths for claude binary', async () => {
// All paths fail - binary not found
accessMock.mockRejectedValue(new Error('ENOENT'));
const result = await probeUnixPaths('claude');
// Should return null since all probes fail
expect(result).toBeNull();
// Should have tried multiple paths
expect(accessMock).toHaveBeenCalled();
});
it('should check both existence and executability', async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
try {
accessMock.mockRejectedValue(new Error('ENOENT'));
const result = await probeUnixPaths('claude');
expect(result).toBeNull();
// Verify access was called with F_OK | X_OK
expect(accessMock).toHaveBeenCalled();
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
});
describe('checkBinaryExists', () => {
let accessMock: ReturnType<typeof vi.spyOn>;
const execMock = vi.mocked(execFileNoThrow);
beforeEach(() => {
accessMock = vi.spyOn(fs.promises, 'access');
});
afterEach(() => {
accessMock.mockRestore();
});
it('should try direct probe first on Unix', async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
try {
// Direct probe finds the binary (first path in the list exists)
accessMock.mockResolvedValueOnce(undefined);
const result = await checkBinaryExists('claude');
expect(result.exists).toBe(true);
expect(result.path).toContain('claude');
// which should not be called if direct probe succeeds
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
it('should fall back to which on Unix if probe fails', async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
try {
// Direct probe fails
accessMock.mockRejectedValue(new Error('ENOENT'));
// which succeeds
execMock.mockResolvedValue({
exitCode: 0,
stdout: '/usr/local/bin/test-binary\n',
stderr: '',
});
const result = await checkBinaryExists('test-binary');
expect(result.exists).toBe(true);
expect(result.path).toBe('/usr/local/bin/test-binary');
expect(execMock).toHaveBeenCalledWith(
'which',
['test-binary'],
undefined,
expect.any(Object)
);
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
it('should use where on Windows', async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
try {
// Direct probe fails
accessMock.mockRejectedValue(new Error('ENOENT'));
// where succeeds
execMock.mockResolvedValue({
exitCode: 0,
stdout: 'C:\\Users\\Test\\AppData\\Roaming\\npm\\test.cmd\r\n',
stderr: '',
});
const result = await checkBinaryExists('test');
expect(result.exists).toBe(true);
expect(execMock).toHaveBeenCalledWith('where', ['test'], undefined, expect.any(Object));
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
it('should return exists: false if binary not found', async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
try {
// Direct probe fails
accessMock.mockRejectedValue(new Error('ENOENT'));
// which fails
execMock.mockResolvedValue({
exitCode: 1,
stdout: '',
stderr: 'not found',
});
const result = await checkBinaryExists('non-existent');
expect(result.exists).toBe(false);
expect(result.path).toBeUndefined();
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
it('should prefer .exe over .cmd on Windows', async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
try {
// Direct probe fails
accessMock.mockRejectedValue(new Error('ENOENT'));
// where returns both .exe and .cmd
execMock.mockResolvedValue({
exitCode: 0,
stdout: 'C:\\path\\to\\binary.cmd\r\nC:\\path\\to\\binary.exe\r\n',
stderr: '',
});
const result = await checkBinaryExists('binary');
expect(result.exists).toBe(true);
expect(result.path).toBe('C:\\path\\to\\binary.exe');
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
it('should handle Windows CRLF line endings', async () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
try {
accessMock.mockRejectedValue(new Error('ENOENT'));
execMock.mockResolvedValue({
exitCode: 0,
stdout: 'C:\\path\\to\\binary.exe\r\n',
stderr: '',
});
const result = await checkBinaryExists('binary');
expect(result.exists).toBe(true);
expect(result.path).toBe('C:\\path\\to\\binary.exe');
// Path should not contain \r
expect(result.path).not.toContain('\r');
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
});
});
describe('BinaryDetectionResult type', () => {
it('should allow exists: true with path', () => {
const result: BinaryDetectionResult = {
exists: true,
path: '/usr/local/bin/claude',
};
expect(result.exists).toBe(true);
expect(result.path).toBeDefined();
});
it('should allow exists: false without path', () => {
const result: BinaryDetectionResult = {
exists: false,
};
expect(result.exists).toBe(false);
expect(result.path).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,221 @@
/**
* Agent Definitions
*
* Contains the configuration definitions for all supported AI agents.
* This includes CLI arguments, configuration options, and default settings.
*
* Separated from agent-detector.ts for better maintainability.
*/
import type { AgentCapabilities } from './agent-capabilities';
// ============ Configuration Types ============
/**
* Configuration option types for agent-specific settings
*/
export interface AgentConfigOption {
key: string; // Storage key
type: 'checkbox' | 'text' | 'number' | 'select';
label: string; // UI label
description: string; // Help text
default: any; // Default value
options?: string[]; // For select type
argBuilder?: (value: any) => string[]; // Converts config value to CLI args
}
/**
* Full agent configuration including runtime detection state
*/
export interface AgentConfig {
id: string;
name: string;
binaryName: string;
command: string;
args: string[]; // Base args always included (excludes batch mode prefix)
available: boolean;
path?: string;
customPath?: string; // User-specified custom path (shown in UI even if not available)
requiresPty?: boolean; // Whether this agent needs a pseudo-terminal
configOptions?: AgentConfigOption[]; // Agent-specific configuration
hidden?: boolean; // If true, agent is hidden from UI (internal use only)
capabilities: AgentCapabilities; // Agent feature capabilities
// Argument builders for dynamic CLI construction
// These are optional - agents that don't have them use hardcoded behavior
batchModePrefix?: string[]; // Args added before base args for batch mode (e.g., ['run'] for OpenCode)
batchModeArgs?: string[]; // Args only applied in batch mode (e.g., ['--skip-git-repo-check'] for Codex exec)
jsonOutputArgs?: string[]; // Args for JSON output format (e.g., ['--format', 'json'])
resumeArgs?: (sessionId: string) => string[]; // Function to build resume args
readOnlyArgs?: string[]; // Args for read-only/plan mode (e.g., ['--agent', 'plan'])
modelArgs?: (modelId: string) => string[]; // Function to build model selection args (e.g., ['--model', modelId])
yoloModeArgs?: string[]; // Args for YOLO/full-access mode (e.g., ['--dangerously-bypass-approvals-and-sandbox'])
workingDirArgs?: (dir: string) => string[]; // Function to build working directory args (e.g., ['-C', dir])
imageArgs?: (imagePath: string) => string[]; // Function to build image attachment args (e.g., ['-i', imagePath] for Codex)
promptArgs?: (prompt: string) => string[]; // Function to build prompt args (e.g., ['-p', prompt] for OpenCode)
noPromptSeparator?: boolean; // If true, don't add '--' before the prompt in batch mode (OpenCode doesn't support it)
defaultEnvVars?: Record<string, string>; // Default environment variables for this agent (merged with user customEnvVars)
}
/**
* Agent definition without runtime detection state (used for static definitions)
*/
export type AgentDefinition = Omit<AgentConfig, 'available' | 'path' | 'capabilities'>;
// ============ Agent Definitions ============
/**
* Static definitions for all supported agents.
* These are the base configurations before runtime detection adds availability info.
*/
export const AGENT_DEFINITIONS: AgentDefinition[] = [
{
id: 'terminal',
name: 'Terminal',
// Use platform-appropriate default shell
binaryName: process.platform === 'win32' ? 'powershell.exe' : 'bash',
command: process.platform === 'win32' ? 'powershell.exe' : 'bash',
args: [],
requiresPty: true,
hidden: true, // Internal agent, not shown in UI
},
{
id: 'claude-code',
name: 'Claude Code',
binaryName: 'claude',
command: 'claude',
// YOLO mode (--dangerously-skip-permissions) is always enabled - Maestro requires it
args: [
'--print',
'--verbose',
'--output-format',
'stream-json',
'--dangerously-skip-permissions',
],
resumeArgs: (sessionId: string) => ['--resume', sessionId], // Resume with session ID
readOnlyArgs: ['--permission-mode', 'plan'], // Read-only/plan mode
},
{
id: 'codex',
name: 'Codex',
binaryName: 'codex',
command: 'codex',
// Base args for interactive mode (no flags that are exec-only)
args: [],
// Codex CLI argument builders
// Batch mode: codex exec --json --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check [--sandbox read-only] [-C dir] [resume <id>] -- "prompt"
// Sandbox modes:
// - Default (YOLO): --dangerously-bypass-approvals-and-sandbox (full system access, required by Maestro)
// - Read-only: --sandbox read-only (can only read files, overrides YOLO)
batchModePrefix: ['exec'], // Codex uses 'exec' subcommand for batch mode
batchModeArgs: ['--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check'], // Args only valid on 'exec' subcommand
jsonOutputArgs: ['--json'], // JSON output format (must come before resume subcommand)
resumeArgs: (sessionId: string) => ['resume', sessionId], // Resume with session/thread ID
readOnlyArgs: ['--sandbox', 'read-only'], // Read-only/plan mode
yoloModeArgs: ['--dangerously-bypass-approvals-and-sandbox'], // Full access mode
workingDirArgs: (dir: string) => ['-C', dir], // Set working directory
imageArgs: (imagePath: string) => ['-i', imagePath], // Image attachment: codex exec -i /path/to/image.png
// Agent-specific configuration options shown in UI
configOptions: [
{
key: 'contextWindow',
type: 'number',
label: 'Context Window Size',
description:
'Maximum context window size in tokens. Required for context usage display. Common values: 400000 (GPT-5.2), 128000 (GPT-4o).',
default: 400000, // Default for GPT-5.2 models
},
],
},
{
id: 'gemini-cli',
name: 'Gemini CLI',
binaryName: 'gemini',
command: 'gemini',
args: [],
},
{
id: 'qwen3-coder',
name: 'Qwen3 Coder',
binaryName: 'qwen3-coder',
command: 'qwen3-coder',
args: [],
},
{
id: 'opencode',
name: 'OpenCode',
binaryName: 'opencode',
command: 'opencode',
args: [], // Base args (none for OpenCode - batch mode uses 'run' subcommand)
// OpenCode CLI argument builders
// Batch mode: opencode run --format json [--model provider/model] [--session <id>] [--agent plan] "prompt"
// YOLO mode (auto-approve all permissions) is enabled via OPENCODE_CONFIG_CONTENT env var.
// This prevents OpenCode from prompting for permission on external_directory access, which would hang in batch mode.
batchModePrefix: ['run'], // OpenCode uses 'run' subcommand for batch mode
jsonOutputArgs: ['--format', 'json'], // JSON output format
resumeArgs: (sessionId: string) => ['--session', sessionId], // Resume with session ID
readOnlyArgs: ['--agent', 'plan'], // Read-only/plan mode
modelArgs: (modelId: string) => ['--model', modelId], // Model selection (e.g., 'ollama/qwen3:8b')
imageArgs: (imagePath: string) => ['-f', imagePath], // Image/file attachment: opencode run -f /path/to/image.png -- "prompt"
noPromptSeparator: true, // OpenCode doesn't need '--' before prompt - yargs handles positional args
// Default env vars: enable YOLO mode (allow all permissions including external_directory)
// Users can override by setting customEnvVars in agent config
defaultEnvVars: {
OPENCODE_CONFIG_CONTENT: '{"permission":{"*":"allow","external_directory":"allow"}}',
},
// Agent-specific configuration options shown in UI
configOptions: [
{
key: 'model',
type: 'text',
label: 'Model',
description:
'Model to use (e.g., "ollama/qwen3:8b", "anthropic/claude-sonnet-4-20250514"). Leave empty for default.',
default: '', // Empty string means use OpenCode's default model
argBuilder: (value: string) => {
// Only add --model arg if a model is specified
if (value && value.trim()) {
return ['--model', value.trim()];
}
return [];
},
},
{
key: 'contextWindow',
type: 'number',
label: 'Context Window Size',
description:
'Maximum context window size in tokens. Required for context usage display. Varies by model (e.g., 400000 for Claude/GPT-5.2, 128000 for GPT-4o).',
default: 128000, // Default for common models (GPT-4, etc.)
},
],
},
{
id: 'aider',
name: 'Aider',
binaryName: 'aider',
command: 'aider',
args: [], // Base args (placeholder - to be configured when implemented)
},
];
/**
* Get an agent definition by ID (without runtime detection state)
*/
export function getAgentDefinition(agentId: string): AgentDefinition | undefined {
return AGENT_DEFINITIONS.find((def) => def.id === agentId);
}
/**
* Get all agent IDs
*/
export function getAgentIds(): string[] {
return AGENT_DEFINITIONS.map((def) => def.id);
}
/**
* Get all visible (non-hidden) agent definitions
*/
export function getVisibleAgentDefinitions(): AgentDefinition[] {
return AGENT_DEFINITIONS.filter((def) => !def.hidden);
}

View File

@@ -1,185 +1,39 @@
/**
* Agent Detector - Detects available AI agents on the system
*
* This module provides the main AgentDetector class that:
* - Detects which agents are available on the system
* - Manages custom paths for agents
* - Caches detection results for performance
* - Discovers available models for agents that support model selection
*
* Architecture:
* - Agent definitions are in agent-definitions.ts
* - Path probing logic is in path-prober.ts
* - Agent capabilities are in agent-capabilities.ts
*/
import * as path from 'path';
import { execFileNoThrow } from './utils/execFile';
import { logger } from './utils/logger';
import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import { AgentCapabilities, getAgentCapabilities } from './agent-capabilities';
import { expandTilde, detectNodeVersionManagerBinPaths } from '../shared/pathUtils';
import { getAgentCapabilities } from './agent-capabilities';
import { checkBinaryExists, checkCustomPath, getExpandedEnv } from './path-prober';
import { AGENT_DEFINITIONS, type AgentConfig } from './agent-definitions';
// ============ Re-exports for API Compatibility ============
// These exports maintain backwards compatibility with existing code
// Re-export AgentCapabilities for convenience
export { AgentCapabilities } from './agent-capabilities';
export {
AGENT_DEFINITIONS,
type AgentConfig,
type AgentConfigOption,
type AgentDefinition,
} from './agent-definitions';
// Configuration option types for agent-specific settings
export interface AgentConfigOption {
key: string; // Storage key
type: 'checkbox' | 'text' | 'number' | 'select';
label: string; // UI label
description: string; // Help text
default: any; // Default value
options?: string[]; // For select type
argBuilder?: (value: any) => string[]; // Converts config value to CLI args
}
const LOG_CONTEXT = 'AgentDetector';
export interface AgentConfig {
id: string;
name: string;
binaryName: string;
command: string;
args: string[]; // Base args always included (excludes batch mode prefix)
available: boolean;
path?: string;
customPath?: string; // User-specified custom path (shown in UI even if not available)
requiresPty?: boolean; // Whether this agent needs a pseudo-terminal
configOptions?: AgentConfigOption[]; // Agent-specific configuration
hidden?: boolean; // If true, agent is hidden from UI (internal use only)
capabilities: AgentCapabilities; // Agent feature capabilities
// Argument builders for dynamic CLI construction
// These are optional - agents that don't have them use hardcoded behavior
batchModePrefix?: string[]; // Args added before base args for batch mode (e.g., ['run'] for OpenCode)
batchModeArgs?: string[]; // Args only applied in batch mode (e.g., ['--skip-git-repo-check'] for Codex exec)
jsonOutputArgs?: string[]; // Args for JSON output format (e.g., ['--format', 'json'])
resumeArgs?: (sessionId: string) => string[]; // Function to build resume args
readOnlyArgs?: string[]; // Args for read-only/plan mode (e.g., ['--agent', 'plan'])
modelArgs?: (modelId: string) => string[]; // Function to build model selection args (e.g., ['--model', modelId])
yoloModeArgs?: string[]; // Args for YOLO/full-access mode (e.g., ['--dangerously-bypass-approvals-and-sandbox'])
workingDirArgs?: (dir: string) => string[]; // Function to build working directory args (e.g., ['-C', dir])
imageArgs?: (imagePath: string) => string[]; // Function to build image attachment args (e.g., ['-i', imagePath] for Codex)
promptArgs?: (prompt: string) => string[]; // Function to build prompt args (e.g., ['-p', prompt] for OpenCode)
noPromptSeparator?: boolean; // If true, don't add '--' before the prompt in batch mode (OpenCode doesn't support it)
defaultEnvVars?: Record<string, string>; // Default environment variables for this agent (merged with user customEnvVars)
}
export const AGENT_DEFINITIONS: Omit<AgentConfig, 'available' | 'path' | 'capabilities'>[] = [
{
id: 'terminal',
name: 'Terminal',
// Use platform-appropriate default shell
binaryName: process.platform === 'win32' ? 'powershell.exe' : 'bash',
command: process.platform === 'win32' ? 'powershell.exe' : 'bash',
args: [],
requiresPty: true,
hidden: true, // Internal agent, not shown in UI
},
{
id: 'claude-code',
name: 'Claude Code',
binaryName: 'claude',
command: 'claude',
// YOLO mode (--dangerously-skip-permissions) is always enabled - Maestro requires it
args: [
'--print',
'--verbose',
'--output-format',
'stream-json',
'--dangerously-skip-permissions',
],
resumeArgs: (sessionId: string) => ['--resume', sessionId], // Resume with session ID
readOnlyArgs: ['--permission-mode', 'plan'], // Read-only/plan mode
},
{
id: 'codex',
name: 'Codex',
binaryName: 'codex',
command: 'codex',
// Base args for interactive mode (no flags that are exec-only)
args: [],
// Codex CLI argument builders
// Batch mode: codex exec --json --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check [--sandbox read-only] [-C dir] [resume <id>] -- "prompt"
// Sandbox modes:
// - Default (YOLO): --dangerously-bypass-approvals-and-sandbox (full system access, required by Maestro)
// - Read-only: --sandbox read-only (can only read files, overrides YOLO)
batchModePrefix: ['exec'], // Codex uses 'exec' subcommand for batch mode
batchModeArgs: ['--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check'], // Args only valid on 'exec' subcommand
jsonOutputArgs: ['--json'], // JSON output format (must come before resume subcommand)
resumeArgs: (sessionId: string) => ['resume', sessionId], // Resume with session/thread ID
readOnlyArgs: ['--sandbox', 'read-only'], // Read-only/plan mode
yoloModeArgs: ['--dangerously-bypass-approvals-and-sandbox'], // Full access mode
workingDirArgs: (dir: string) => ['-C', dir], // Set working directory
imageArgs: (imagePath: string) => ['-i', imagePath], // Image attachment: codex exec -i /path/to/image.png
// Agent-specific configuration options shown in UI
configOptions: [
{
key: 'contextWindow',
type: 'number',
label: 'Context Window Size',
description:
'Maximum context window size in tokens. Required for context usage display. Common values: 400000 (GPT-5.2), 128000 (GPT-4o).',
default: 400000, // Default for GPT-5.2 models
},
],
},
{
id: 'gemini-cli',
name: 'Gemini CLI',
binaryName: 'gemini',
command: 'gemini',
args: [],
},
{
id: 'qwen3-coder',
name: 'Qwen3 Coder',
binaryName: 'qwen3-coder',
command: 'qwen3-coder',
args: [],
},
{
id: 'opencode',
name: 'OpenCode',
binaryName: 'opencode',
command: 'opencode',
args: [], // Base args (none for OpenCode - batch mode uses 'run' subcommand)
// OpenCode CLI argument builders
// Batch mode: opencode run --format json [--model provider/model] [--session <id>] [--agent plan] "prompt"
// YOLO mode (auto-approve all permissions) is enabled via OPENCODE_CONFIG_CONTENT env var.
// This prevents OpenCode from prompting for permission on external_directory access, which would hang in batch mode.
batchModePrefix: ['run'], // OpenCode uses 'run' subcommand for batch mode
jsonOutputArgs: ['--format', 'json'], // JSON output format
resumeArgs: (sessionId: string) => ['--session', sessionId], // Resume with session ID
readOnlyArgs: ['--agent', 'plan'], // Read-only/plan mode
modelArgs: (modelId: string) => ['--model', modelId], // Model selection (e.g., 'ollama/qwen3:8b')
imageArgs: (imagePath: string) => ['-f', imagePath], // Image/file attachment: opencode run -f /path/to/image.png -- "prompt"
noPromptSeparator: true, // OpenCode doesn't need '--' before prompt - yargs handles positional args
// Default env vars: enable YOLO mode (allow all permissions including external_directory)
// Users can override by setting customEnvVars in agent config
defaultEnvVars: {
OPENCODE_CONFIG_CONTENT: '{"permission":{"*":"allow","external_directory":"allow"}}',
},
// Agent-specific configuration options shown in UI
configOptions: [
{
key: 'model',
type: 'text',
label: 'Model',
description:
'Model to use (e.g., "ollama/qwen3:8b", "anthropic/claude-sonnet-4-20250514"). Leave empty for default.',
default: '', // Empty string means use OpenCode's default model
argBuilder: (value: string) => {
// Only add --model arg if a model is specified
if (value && value.trim()) {
return ['--model', value.trim()];
}
return [];
},
},
{
key: 'contextWindow',
type: 'number',
label: 'Context Window Size',
description:
'Maximum context window size in tokens. Required for context usage display. Varies by model (e.g., 400000 for Claude/GPT-5.2, 128000 for GPT-4o).',
default: 128000, // Default for common models (GPT-4, etc.)
},
],
},
{
id: 'aider',
name: 'Aider',
binaryName: 'aider',
command: 'aider',
args: [], // Base args (placeholder - to be configured when implemented)
},
];
// ============ Agent Detector Class ============
export class AgentDetector {
private cachedAgents: AgentConfig[] | null = null;
@@ -234,9 +88,9 @@ export class AgentDetector {
*/
private async doDetectAgents(): Promise<AgentConfig[]> {
const agents: AgentConfig[] = [];
const expandedEnv = this.getExpandedEnv();
const expandedEnv = getExpandedEnv();
logger.info(`Agent detection starting. PATH: ${expandedEnv.PATH}`, 'AgentDetector');
logger.info(`Agent detection starting. PATH: ${expandedEnv.PATH}`, LOG_CONTEXT);
for (const agentDef of AGENT_DEFINITIONS) {
const customPath = this.customPaths[agentDef.id];
@@ -244,37 +98,34 @@ export class AgentDetector {
// If user has specified a custom path, check that first
if (customPath) {
detection = await this.checkCustomPath(customPath);
detection = await checkCustomPath(customPath);
if (detection.exists) {
logger.info(
`Agent "${agentDef.name}" found at custom path: ${detection.path}`,
'AgentDetector'
LOG_CONTEXT
);
} else {
logger.warn(
`Agent "${agentDef.name}" custom path not valid: ${customPath}`,
'AgentDetector'
);
logger.warn(`Agent "${agentDef.name}" custom path not valid: ${customPath}`, LOG_CONTEXT);
// Fall back to PATH detection
detection = await this.checkBinaryExists(agentDef.binaryName);
detection = await checkBinaryExists(agentDef.binaryName);
if (detection.exists) {
logger.info(
`Agent "${agentDef.name}" found in PATH at: ${detection.path}`,
'AgentDetector'
LOG_CONTEXT
);
}
}
} else {
detection = await this.checkBinaryExists(agentDef.binaryName);
detection = await checkBinaryExists(agentDef.binaryName);
if (detection.exists) {
logger.info(`Agent "${agentDef.name}" found at: ${detection.path}`, 'AgentDetector');
logger.info(`Agent "${agentDef.name}" found at: ${detection.path}`, LOG_CONTEXT);
} else if (agentDef.binaryName !== 'bash') {
// Don't log bash as missing since it's always present, log others as warnings
logger.warn(
`Agent "${agentDef.name}" (binary: ${agentDef.binaryName}) not found. ` +
`Searched in PATH: ${expandedEnv.PATH}`,
'AgentDetector'
LOG_CONTEXT
);
}
}
@@ -293,7 +144,7 @@ export class AgentDetector {
// On Windows, log detailed path info to help debug shell execution issues
if (isWindows) {
logger.info(`Agent detection complete (Windows)`, 'AgentDetector', {
logger.info(`Agent detection complete (Windows)`, LOG_CONTEXT, {
platform: process.platform,
agents: availableAgents.map((a) => ({
id: a.id,
@@ -311,7 +162,7 @@ export class AgentDetector {
} else {
logger.info(
`Agent detection complete. Available: ${availableAgents.map((a) => a.name).join(', ') || 'none'}`,
'AgentDetector'
LOG_CONTEXT
);
}
@@ -319,442 +170,6 @@ export class AgentDetector {
return agents;
}
/**
* Check if a custom path points to a valid executable
* On Windows, also tries .cmd and .exe extensions if the path doesn't exist as-is
*/
private async checkCustomPath(customPath: string): Promise<{ exists: boolean; path?: string }> {
const isWindows = process.platform === 'win32';
// Expand tilde to home directory (Node.js fs doesn't understand ~)
const expandedPath = expandTilde(customPath);
// Helper to check if a specific path exists and is a file
const checkPath = async (pathToCheck: string): Promise<boolean> => {
try {
const stats = await fs.promises.stat(pathToCheck);
return stats.isFile();
} catch {
return false;
}
};
try {
// First, try the exact path provided (with tilde expanded)
if (await checkPath(expandedPath)) {
// Check if file is executable (on Unix systems)
if (!isWindows) {
try {
await fs.promises.access(expandedPath, fs.constants.X_OK);
} catch {
logger.warn(`Custom path exists but is not executable: ${customPath}`, 'AgentDetector');
return { exists: false };
}
}
// Return the expanded path so it can be used directly
return { exists: true, path: expandedPath };
}
// On Windows, if the exact path doesn't exist, try with .cmd and .exe extensions
if (isWindows) {
const lowerPath = expandedPath.toLowerCase();
// Only try extensions if the path doesn't already have one
if (!lowerPath.endsWith('.cmd') && !lowerPath.endsWith('.exe')) {
// Try .exe first (preferred), then .cmd
const exePath = expandedPath + '.exe';
if (await checkPath(exePath)) {
logger.debug(`Custom path resolved with .exe extension`, 'AgentDetector', {
original: customPath,
resolved: exePath,
});
return { exists: true, path: exePath };
}
const cmdPath = expandedPath + '.cmd';
if (await checkPath(cmdPath)) {
logger.debug(`Custom path resolved with .cmd extension`, 'AgentDetector', {
original: customPath,
resolved: cmdPath,
});
return { exists: true, path: cmdPath };
}
}
}
return { exists: false };
} catch {
return { exists: false };
}
}
/**
* Build an expanded PATH that includes common binary installation locations.
* This is necessary because packaged Electron apps don't inherit shell environment.
*/
private getExpandedEnv(): NodeJS.ProcessEnv {
const home = os.homedir();
const env = { ...process.env };
const isWindows = process.platform === 'win32';
// Platform-specific paths
let additionalPaths: string[];
if (isWindows) {
// Windows-specific paths
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
additionalPaths = [
// Claude Code PowerShell installer (irm https://claude.ai/install.ps1 | iex)
// This is the primary installation method - installs claude.exe to ~/.local/bin
path.join(home, '.local', 'bin'),
// Claude Code winget install (winget install --id Anthropic.ClaudeCode)
path.join(localAppData, 'Microsoft', 'WinGet', 'Links'),
path.join(programFiles, 'WinGet', 'Links'),
path.join(localAppData, 'Microsoft', 'WinGet', 'Packages'),
path.join(programFiles, 'WinGet', 'Packages'),
// npm global installs (Claude Code, Codex CLI, Gemini CLI)
path.join(appData, 'npm'),
path.join(localAppData, 'npm'),
// Claude Code CLI install location (npm global)
path.join(appData, 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'cli'),
// Codex CLI install location (npm global)
path.join(appData, 'npm', 'node_modules', '@openai', 'codex', 'bin'),
// User local programs
path.join(localAppData, 'Programs'),
path.join(localAppData, 'Microsoft', 'WindowsApps'),
// Python/pip user installs (for Aider)
path.join(appData, 'Python', 'Scripts'),
path.join(localAppData, 'Programs', 'Python', 'Python312', 'Scripts'),
path.join(localAppData, 'Programs', 'Python', 'Python311', 'Scripts'),
path.join(localAppData, 'Programs', 'Python', 'Python310', 'Scripts'),
// Git for Windows (provides bash, common tools)
path.join(programFiles, 'Git', 'cmd'),
path.join(programFiles, 'Git', 'bin'),
path.join(programFiles, 'Git', 'usr', 'bin'),
path.join(programFilesX86, 'Git', 'cmd'),
path.join(programFilesX86, 'Git', 'bin'),
// Node.js
path.join(programFiles, 'nodejs'),
path.join(localAppData, 'Programs', 'node'),
// Scoop package manager (OpenCode, other tools)
path.join(home, 'scoop', 'shims'),
path.join(home, 'scoop', 'apps', 'opencode', 'current'),
// Chocolatey (OpenCode, other tools)
path.join(process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey', 'bin'),
// Go binaries (some tools installed via 'go install')
path.join(home, 'go', 'bin'),
// Windows system paths
path.join(process.env.SystemRoot || 'C:\\Windows', 'System32'),
path.join(process.env.SystemRoot || 'C:\\Windows'),
];
} else {
// Unix-like paths (macOS/Linux)
additionalPaths = [
'/opt/homebrew/bin', // Homebrew on Apple Silicon
'/opt/homebrew/sbin',
'/usr/local/bin', // Homebrew on Intel, common install location
'/usr/local/sbin',
`${home}/.local/bin`, // User local installs (pip, etc.)
`${home}/.npm-global/bin`, // npm global with custom prefix
`${home}/bin`, // User bin directory
`${home}/.claude/local`, // Claude local install location
`${home}/.opencode/bin`, // OpenCode installer default location
'/usr/bin',
'/bin',
'/usr/sbin',
'/sbin',
];
}
const currentPath = env.PATH || '';
// Use platform-appropriate path delimiter
const pathParts = currentPath.split(path.delimiter);
// Add paths that aren't already present
for (const p of additionalPaths) {
if (!pathParts.includes(p)) {
pathParts.unshift(p);
}
}
env.PATH = pathParts.join(path.delimiter);
return env;
}
/**
* On Windows, directly probe known installation paths for a binary.
* This is more reliable than `where` command which may fail in packaged Electron apps.
* Returns the first existing path found, preferring .exe over .cmd.
*/
private async probeWindowsPaths(binaryName: string): Promise<string | null> {
const home = os.homedir();
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
// Define known installation paths for each binary, in priority order
// Prefer .exe (standalone installers) over .cmd (npm wrappers)
const knownPaths: Record<string, string[]> = {
claude: [
// PowerShell installer (primary method) - installs claude.exe
path.join(home, '.local', 'bin', 'claude.exe'),
// Winget installation
path.join(localAppData, 'Microsoft', 'WinGet', 'Links', 'claude.exe'),
path.join(programFiles, 'WinGet', 'Links', 'claude.exe'),
// npm global installation - creates .cmd wrapper
path.join(appData, 'npm', 'claude.cmd'),
path.join(localAppData, 'npm', 'claude.cmd'),
// WindowsApps (Microsoft Store style)
path.join(localAppData, 'Microsoft', 'WindowsApps', 'claude.exe'),
],
codex: [
// npm global installation (primary method for Codex)
path.join(appData, 'npm', 'codex.cmd'),
path.join(localAppData, 'npm', 'codex.cmd'),
// Possible standalone in future
path.join(home, '.local', 'bin', 'codex.exe'),
],
opencode: [
// Scoop installation (recommended for OpenCode)
path.join(home, 'scoop', 'shims', 'opencode.exe'),
path.join(home, 'scoop', 'apps', 'opencode', 'current', 'opencode.exe'),
// Chocolatey installation
path.join(
process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey',
'bin',
'opencode.exe'
),
// Go install
path.join(home, 'go', 'bin', 'opencode.exe'),
// npm (has known issues on Windows, but check anyway)
path.join(appData, 'npm', 'opencode.cmd'),
],
gemini: [
// npm global installation
path.join(appData, 'npm', 'gemini.cmd'),
path.join(localAppData, 'npm', 'gemini.cmd'),
],
aider: [
// pip installation
path.join(appData, 'Python', 'Scripts', 'aider.exe'),
path.join(localAppData, 'Programs', 'Python', 'Python312', 'Scripts', 'aider.exe'),
path.join(localAppData, 'Programs', 'Python', 'Python311', 'Scripts', 'aider.exe'),
path.join(localAppData, 'Programs', 'Python', 'Python310', 'Scripts', 'aider.exe'),
],
};
const pathsToCheck = knownPaths[binaryName] || [];
for (const probePath of pathsToCheck) {
try {
await fs.promises.access(probePath, fs.constants.F_OK);
logger.debug(`Direct probe found ${binaryName}`, 'AgentDetector', { path: probePath });
return probePath;
} catch {
// Path doesn't exist, continue to next
}
}
return null;
}
/**
* On macOS/Linux, directly probe known installation paths for a binary.
* This is necessary because packaged Electron apps don't inherit shell aliases,
* and 'which' may fail to find binaries in non-standard locations.
* Returns the first existing executable path found.
*/
private async probeUnixPaths(binaryName: string): Promise<string | null> {
const home = os.homedir();
// Get dynamic paths from Node version managers (nvm, fnm, volta, etc.)
const versionManagerPaths = detectNodeVersionManagerBinPaths();
// Define known installation paths for each binary, in priority order
const knownPaths: Record<string, string[]> = {
claude: [
// Claude Code default installation location (irm https://claude.ai/install.ps1 equivalent on macOS)
path.join(home, '.claude', 'local', 'claude'),
// User local bin (pip, manual installs)
path.join(home, '.local', 'bin', 'claude'),
// Homebrew on Apple Silicon
'/opt/homebrew/bin/claude',
// Homebrew on Intel Mac
'/usr/local/bin/claude',
// npm global with custom prefix
path.join(home, '.npm-global', 'bin', 'claude'),
// User bin directory
path.join(home, 'bin', 'claude'),
// Add paths from Node version managers (nvm, fnm, volta, etc.)
...versionManagerPaths.map((p) => path.join(p, 'claude')),
],
codex: [
// User local bin
path.join(home, '.local', 'bin', 'codex'),
// Homebrew paths
'/opt/homebrew/bin/codex',
'/usr/local/bin/codex',
// npm global
path.join(home, '.npm-global', 'bin', 'codex'),
// Add paths from Node version managers (nvm, fnm, volta, etc.)
...versionManagerPaths.map((p) => path.join(p, 'codex')),
],
opencode: [
// OpenCode installer default location
path.join(home, '.opencode', 'bin', 'opencode'),
// Go install location
path.join(home, 'go', 'bin', 'opencode'),
// User local bin
path.join(home, '.local', 'bin', 'opencode'),
// Homebrew paths
'/opt/homebrew/bin/opencode',
'/usr/local/bin/opencode',
// Add paths from Node version managers (nvm, fnm, volta, etc.)
...versionManagerPaths.map((p) => path.join(p, 'opencode')),
],
gemini: [
// npm global paths
path.join(home, '.npm-global', 'bin', 'gemini'),
'/opt/homebrew/bin/gemini',
'/usr/local/bin/gemini',
// Add paths from Node version managers (nvm, fnm, volta, etc.)
...versionManagerPaths.map((p) => path.join(p, 'gemini')),
],
aider: [
// pip installation
path.join(home, '.local', 'bin', 'aider'),
// Homebrew paths
'/opt/homebrew/bin/aider',
'/usr/local/bin/aider',
// Add paths from Node version managers (in case installed via npm)
...versionManagerPaths.map((p) => path.join(p, 'aider')),
],
};
const pathsToCheck = knownPaths[binaryName] || [];
for (const probePath of pathsToCheck) {
try {
// Check both existence and executability
await fs.promises.access(probePath, fs.constants.F_OK | fs.constants.X_OK);
logger.debug(`Direct probe found ${binaryName}`, 'AgentDetector', { path: probePath });
return probePath;
} catch {
// Path doesn't exist or isn't executable, continue to next
}
}
return null;
}
/**
* Check if a binary exists in PATH
* On Windows, this also handles .cmd and .exe extensions properly
*/
private async checkBinaryExists(binaryName: string): Promise<{ exists: boolean; path?: string }> {
const isWindows = process.platform === 'win32';
// First try direct file probing of known installation paths
// This is more reliable than which/where in packaged Electron apps
if (isWindows) {
const probedPath = await this.probeWindowsPaths(binaryName);
if (probedPath) {
return { exists: true, path: probedPath };
}
logger.debug(`Direct probe failed for ${binaryName}, falling back to where`, 'AgentDetector');
} else {
// macOS/Linux: probe known paths first
const probedPath = await this.probeUnixPaths(binaryName);
if (probedPath) {
return { exists: true, path: probedPath };
}
logger.debug(`Direct probe failed for ${binaryName}, falling back to which`, 'AgentDetector');
}
try {
// Use 'which' on Unix-like systems, 'where' on Windows
const command = isWindows ? 'where' : 'which';
// Use expanded PATH to find binaries in common installation locations
// This is critical for packaged Electron apps which don't inherit shell env
const env = this.getExpandedEnv();
const result = await execFileNoThrow(command, [binaryName], undefined, env);
if (result.exitCode === 0 && result.stdout.trim()) {
// Get all matches (Windows 'where' can return multiple)
// Handle both Unix (\n) and Windows (\r\n) line endings
const matches = result.stdout
.trim()
.split(/\r?\n/)
.map((p) => p.trim())
.filter((p) => p);
if (process.platform === 'win32' && matches.length > 0) {
// On Windows, prefer .exe over .cmd over extensionless
// This helps with proper execution handling
const exeMatch = matches.find((p) => p.toLowerCase().endsWith('.exe'));
const cmdMatch = matches.find((p) => p.toLowerCase().endsWith('.cmd'));
// Return the best match: .exe > .cmd > first result
let bestMatch = exeMatch || cmdMatch || matches[0];
// If the first match doesn't have an extension, check if .cmd or .exe version exists
// This handles cases where 'where' returns a path without extension
if (
!bestMatch.toLowerCase().endsWith('.exe') &&
!bestMatch.toLowerCase().endsWith('.cmd')
) {
const cmdPath = bestMatch + '.cmd';
const exePath = bestMatch + '.exe';
// Check if the .exe or .cmd version exists
try {
await fs.promises.access(exePath, fs.constants.F_OK);
bestMatch = exePath;
logger.debug(`Found .exe version of ${binaryName}`, 'AgentDetector', {
path: exePath,
});
} catch {
try {
await fs.promises.access(cmdPath, fs.constants.F_OK);
bestMatch = cmdPath;
logger.debug(`Found .cmd version of ${binaryName}`, 'AgentDetector', {
path: cmdPath,
});
} catch {
// Neither .exe nor .cmd exists, use the original path
}
}
}
logger.debug(`Windows binary detection for ${binaryName}`, 'AgentDetector', {
allMatches: matches,
selectedMatch: bestMatch,
isCmd: bestMatch.toLowerCase().endsWith('.cmd'),
isExe: bestMatch.toLowerCase().endsWith('.exe'),
});
return {
exists: true,
path: bestMatch,
};
}
return {
exists: true,
path: matches[0], // First match for Unix
};
}
return { exists: false };
} catch {
return { exists: false };
}
}
/**
* Get a specific agent by ID
*/
@@ -793,13 +208,13 @@ export class AgentDetector {
const agent = await this.getAgent(agentId);
if (!agent || !agent.available) {
logger.warn(`Cannot discover models: agent ${agentId} not available`, 'AgentDetector');
logger.warn(`Cannot discover models: agent ${agentId} not available`, LOG_CONTEXT);
return [];
}
// Check if agent supports model selection
if (!agent.capabilities.supportsModelSelection) {
logger.debug(`Agent ${agentId} does not support model selection`, 'AgentDetector');
logger.debug(`Agent ${agentId} does not support model selection`, LOG_CONTEXT);
return [];
}
@@ -807,7 +222,7 @@ export class AgentDetector {
if (!forceRefresh) {
const cached = this.modelCache.get(agentId);
if (cached && Date.now() - cached.timestamp < this.MODEL_CACHE_TTL_MS) {
logger.debug(`Returning cached models for ${agentId}`, 'AgentDetector');
logger.debug(`Returning cached models for ${agentId}`, LOG_CONTEXT);
return cached.models;
}
}
@@ -826,7 +241,7 @@ export class AgentDetector {
* Each agent may have a different way to list available models.
*/
private async runModelDiscovery(agentId: string, agent: AgentConfig): Promise<string[]> {
const env = this.getExpandedEnv();
const env = getExpandedEnv();
const command = agent.path || agent.command;
// Agent-specific model discovery commands
@@ -838,7 +253,7 @@ export class AgentDetector {
if (result.exitCode !== 0) {
logger.warn(
`Model discovery failed for ${agentId}: exit code ${result.exitCode}`,
'AgentDetector',
LOG_CONTEXT,
{ stderr: result.stderr }
);
return [];
@@ -850,7 +265,7 @@ export class AgentDetector {
.map((line) => line.trim())
.filter((line) => line.length > 0);
logger.info(`Discovered ${models.length} models for ${agentId}`, 'AgentDetector', {
logger.info(`Discovered ${models.length} models for ${agentId}`, LOG_CONTEXT, {
models,
});
return models;
@@ -858,7 +273,7 @@ export class AgentDetector {
default:
// For agents without model discovery implemented, return empty array
logger.debug(`No model discovery implemented for ${agentId}`, 'AgentDetector');
logger.debug(`No model discovery implemented for ${agentId}`, LOG_CONTEXT);
return [];
}
}

489
src/main/path-prober.ts Normal file
View File

@@ -0,0 +1,489 @@
/**
* Path Prober - Platform-specific binary detection
*
* Handles detection of agent binaries on Windows and Unix-like systems.
* Packaged Electron apps don't inherit shell environment, so we need to
* probe known installation paths directly.
*
* Separated from agent-detector.ts for better maintainability and testability.
*/
import * as os from 'os';
import * as fs from 'fs';
import * as path from 'path';
import { execFileNoThrow } from './utils/execFile';
import { logger } from './utils/logger';
import { expandTilde, detectNodeVersionManagerBinPaths } from '../shared/pathUtils';
const LOG_CONTEXT = 'PathProber';
// ============ Types ============
export interface BinaryDetectionResult {
exists: boolean;
path?: string;
}
// ============ Environment Expansion ============
/**
* Build an expanded PATH that includes common binary installation locations.
* This is necessary because packaged Electron apps don't inherit shell environment.
*/
export function getExpandedEnv(): NodeJS.ProcessEnv {
const home = os.homedir();
const env = { ...process.env };
const isWindows = process.platform === 'win32';
// Platform-specific paths
let additionalPaths: string[];
if (isWindows) {
// Windows-specific paths
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
const programFilesX86 = process.env['ProgramFiles(x86)'] || 'C:\\Program Files (x86)';
additionalPaths = [
// Claude Code PowerShell installer (irm https://claude.ai/install.ps1 | iex)
// This is the primary installation method - installs claude.exe to ~/.local/bin
path.join(home, '.local', 'bin'),
// Claude Code winget install (winget install --id Anthropic.ClaudeCode)
path.join(localAppData, 'Microsoft', 'WinGet', 'Links'),
path.join(programFiles, 'WinGet', 'Links'),
path.join(localAppData, 'Microsoft', 'WinGet', 'Packages'),
path.join(programFiles, 'WinGet', 'Packages'),
// npm global installs (Claude Code, Codex CLI, Gemini CLI)
path.join(appData, 'npm'),
path.join(localAppData, 'npm'),
// Claude Code CLI install location (npm global)
path.join(appData, 'npm', 'node_modules', '@anthropic-ai', 'claude-code', 'cli'),
// Codex CLI install location (npm global)
path.join(appData, 'npm', 'node_modules', '@openai', 'codex', 'bin'),
// User local programs
path.join(localAppData, 'Programs'),
path.join(localAppData, 'Microsoft', 'WindowsApps'),
// Python/pip user installs (for Aider)
path.join(appData, 'Python', 'Scripts'),
path.join(localAppData, 'Programs', 'Python', 'Python312', 'Scripts'),
path.join(localAppData, 'Programs', 'Python', 'Python311', 'Scripts'),
path.join(localAppData, 'Programs', 'Python', 'Python310', 'Scripts'),
// Git for Windows (provides bash, common tools)
path.join(programFiles, 'Git', 'cmd'),
path.join(programFiles, 'Git', 'bin'),
path.join(programFiles, 'Git', 'usr', 'bin'),
path.join(programFilesX86, 'Git', 'cmd'),
path.join(programFilesX86, 'Git', 'bin'),
// Node.js
path.join(programFiles, 'nodejs'),
path.join(localAppData, 'Programs', 'node'),
// Scoop package manager (OpenCode, other tools)
path.join(home, 'scoop', 'shims'),
path.join(home, 'scoop', 'apps', 'opencode', 'current'),
// Chocolatey (OpenCode, other tools)
path.join(process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey', 'bin'),
// Go binaries (some tools installed via 'go install')
path.join(home, 'go', 'bin'),
// Windows system paths
path.join(process.env.SystemRoot || 'C:\\Windows', 'System32'),
path.join(process.env.SystemRoot || 'C:\\Windows'),
];
} else {
// Unix-like paths (macOS/Linux)
additionalPaths = [
'/opt/homebrew/bin', // Homebrew on Apple Silicon
'/opt/homebrew/sbin',
'/usr/local/bin', // Homebrew on Intel, common install location
'/usr/local/sbin',
`${home}/.local/bin`, // User local installs (pip, etc.)
`${home}/.npm-global/bin`, // npm global with custom prefix
`${home}/bin`, // User bin directory
`${home}/.claude/local`, // Claude local install location
`${home}/.opencode/bin`, // OpenCode installer default location
'/usr/bin',
'/bin',
'/usr/sbin',
'/sbin',
];
}
const currentPath = env.PATH || '';
// Use platform-appropriate path delimiter
const pathParts = currentPath.split(path.delimiter);
// Add paths that aren't already present
for (const p of additionalPaths) {
if (!pathParts.includes(p)) {
pathParts.unshift(p);
}
}
env.PATH = pathParts.join(path.delimiter);
return env;
}
// ============ Custom Path Validation ============
/**
* Check if a custom path points to a valid executable
* On Windows, also tries .cmd and .exe extensions if the path doesn't exist as-is
*/
export async function checkCustomPath(customPath: string): Promise<BinaryDetectionResult> {
const isWindows = process.platform === 'win32';
// Expand tilde to home directory (Node.js fs doesn't understand ~)
const expandedPath = expandTilde(customPath);
// Helper to check if a specific path exists and is a file
const checkPath = async (pathToCheck: string): Promise<boolean> => {
try {
const stats = await fs.promises.stat(pathToCheck);
return stats.isFile();
} catch {
return false;
}
};
try {
// First, try the exact path provided (with tilde expanded)
if (await checkPath(expandedPath)) {
// Check if file is executable (on Unix systems)
if (!isWindows) {
try {
await fs.promises.access(expandedPath, fs.constants.X_OK);
} catch {
logger.warn(`Custom path exists but is not executable: ${customPath}`, LOG_CONTEXT);
return { exists: false };
}
}
// Return the expanded path so it can be used directly
return { exists: true, path: expandedPath };
}
// On Windows, if the exact path doesn't exist, try with .cmd and .exe extensions
if (isWindows) {
const lowerPath = expandedPath.toLowerCase();
// Only try extensions if the path doesn't already have one
if (!lowerPath.endsWith('.cmd') && !lowerPath.endsWith('.exe')) {
// Try .exe first (preferred), then .cmd
const exePath = expandedPath + '.exe';
if (await checkPath(exePath)) {
logger.debug(`Custom path resolved with .exe extension`, LOG_CONTEXT, {
original: customPath,
resolved: exePath,
});
return { exists: true, path: exePath };
}
const cmdPath = expandedPath + '.cmd';
if (await checkPath(cmdPath)) {
logger.debug(`Custom path resolved with .cmd extension`, LOG_CONTEXT, {
original: customPath,
resolved: cmdPath,
});
return { exists: true, path: cmdPath };
}
}
}
return { exists: false };
} catch {
return { exists: false };
}
}
// ============ Windows Path Probing ============
/**
* Known installation paths for binaries on Windows
*/
function getWindowsKnownPaths(binaryName: string): string[] {
const home = os.homedir();
const appData = process.env.APPDATA || path.join(home, 'AppData', 'Roaming');
const localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
// Define known installation paths for each binary, in priority order
// Prefer .exe (standalone installers) over .cmd (npm wrappers)
const knownPaths: Record<string, string[]> = {
claude: [
// PowerShell installer (primary method) - installs claude.exe
path.join(home, '.local', 'bin', 'claude.exe'),
// Winget installation
path.join(localAppData, 'Microsoft', 'WinGet', 'Links', 'claude.exe'),
path.join(programFiles, 'WinGet', 'Links', 'claude.exe'),
// npm global installation - creates .cmd wrapper
path.join(appData, 'npm', 'claude.cmd'),
path.join(localAppData, 'npm', 'claude.cmd'),
// WindowsApps (Microsoft Store style)
path.join(localAppData, 'Microsoft', 'WindowsApps', 'claude.exe'),
],
codex: [
// npm global installation (primary method for Codex)
path.join(appData, 'npm', 'codex.cmd'),
path.join(localAppData, 'npm', 'codex.cmd'),
// Possible standalone in future
path.join(home, '.local', 'bin', 'codex.exe'),
],
opencode: [
// Scoop installation (recommended for OpenCode)
path.join(home, 'scoop', 'shims', 'opencode.exe'),
path.join(home, 'scoop', 'apps', 'opencode', 'current', 'opencode.exe'),
// Chocolatey installation
path.join(
process.env.ChocolateyInstall || 'C:\\ProgramData\\chocolatey',
'bin',
'opencode.exe'
),
// Go install
path.join(home, 'go', 'bin', 'opencode.exe'),
// npm (has known issues on Windows, but check anyway)
path.join(appData, 'npm', 'opencode.cmd'),
],
gemini: [
// npm global installation
path.join(appData, 'npm', 'gemini.cmd'),
path.join(localAppData, 'npm', 'gemini.cmd'),
],
aider: [
// pip installation
path.join(appData, 'Python', 'Scripts', 'aider.exe'),
path.join(localAppData, 'Programs', 'Python', 'Python312', 'Scripts', 'aider.exe'),
path.join(localAppData, 'Programs', 'Python', 'Python311', 'Scripts', 'aider.exe'),
path.join(localAppData, 'Programs', 'Python', 'Python310', 'Scripts', 'aider.exe'),
],
};
return knownPaths[binaryName] || [];
}
/**
* On Windows, directly probe known installation paths for a binary.
* This is more reliable than `where` command which may fail in packaged Electron apps.
* Returns the first existing path found, preferring .exe over .cmd.
*/
export async function probeWindowsPaths(binaryName: string): Promise<string | null> {
const pathsToCheck = getWindowsKnownPaths(binaryName);
for (const probePath of pathsToCheck) {
try {
await fs.promises.access(probePath, fs.constants.F_OK);
logger.debug(`Direct probe found ${binaryName}`, LOG_CONTEXT, { path: probePath });
return probePath;
} catch {
// Path doesn't exist, continue to next
}
}
return null;
}
// ============ Unix Path Probing ============
/**
* Known installation paths for binaries on Unix-like systems
*/
function getUnixKnownPaths(binaryName: string): string[] {
const home = os.homedir();
// Get dynamic paths from Node version managers (nvm, fnm, volta, etc.)
const versionManagerPaths = detectNodeVersionManagerBinPaths();
// Define known installation paths for each binary, in priority order
const knownPaths: Record<string, string[]> = {
claude: [
// Claude Code default installation location (irm https://claude.ai/install.ps1 equivalent on macOS)
path.join(home, '.claude', 'local', 'claude'),
// User local bin (pip, manual installs)
path.join(home, '.local', 'bin', 'claude'),
// Homebrew on Apple Silicon
'/opt/homebrew/bin/claude',
// Homebrew on Intel Mac
'/usr/local/bin/claude',
// npm global with custom prefix
path.join(home, '.npm-global', 'bin', 'claude'),
// User bin directory
path.join(home, 'bin', 'claude'),
// Add paths from Node version managers (nvm, fnm, volta, etc.)
...versionManagerPaths.map((p) => path.join(p, 'claude')),
],
codex: [
// User local bin
path.join(home, '.local', 'bin', 'codex'),
// Homebrew paths
'/opt/homebrew/bin/codex',
'/usr/local/bin/codex',
// npm global
path.join(home, '.npm-global', 'bin', 'codex'),
// Add paths from Node version managers (nvm, fnm, volta, etc.)
...versionManagerPaths.map((p) => path.join(p, 'codex')),
],
opencode: [
// OpenCode installer default location
path.join(home, '.opencode', 'bin', 'opencode'),
// Go install location
path.join(home, 'go', 'bin', 'opencode'),
// User local bin
path.join(home, '.local', 'bin', 'opencode'),
// Homebrew paths
'/opt/homebrew/bin/opencode',
'/usr/local/bin/opencode',
// Add paths from Node version managers (nvm, fnm, volta, etc.)
...versionManagerPaths.map((p) => path.join(p, 'opencode')),
],
gemini: [
// npm global paths
path.join(home, '.npm-global', 'bin', 'gemini'),
'/opt/homebrew/bin/gemini',
'/usr/local/bin/gemini',
// Add paths from Node version managers (nvm, fnm, volta, etc.)
...versionManagerPaths.map((p) => path.join(p, 'gemini')),
],
aider: [
// pip installation
path.join(home, '.local', 'bin', 'aider'),
// Homebrew paths
'/opt/homebrew/bin/aider',
'/usr/local/bin/aider',
// Add paths from Node version managers (in case installed via npm)
...versionManagerPaths.map((p) => path.join(p, 'aider')),
],
};
return knownPaths[binaryName] || [];
}
/**
* On macOS/Linux, directly probe known installation paths for a binary.
* This is necessary because packaged Electron apps don't inherit shell aliases,
* and 'which' may fail to find binaries in non-standard locations.
* Returns the first existing executable path found.
*/
export async function probeUnixPaths(binaryName: string): Promise<string | null> {
const pathsToCheck = getUnixKnownPaths(binaryName);
for (const probePath of pathsToCheck) {
try {
// Check both existence and executability
await fs.promises.access(probePath, fs.constants.F_OK | fs.constants.X_OK);
logger.debug(`Direct probe found ${binaryName}`, LOG_CONTEXT, { path: probePath });
return probePath;
} catch {
// Path doesn't exist or isn't executable, continue to next
}
}
return null;
}
// ============ Binary Detection ============
/**
* Check if a binary exists in PATH or known installation locations.
* On Windows, this also handles .cmd and .exe extensions properly.
*
* Detection order:
* 1. Direct probe of known installation paths (most reliable)
* 2. Fall back to which/where command with expanded PATH
*/
export async function checkBinaryExists(binaryName: string): Promise<BinaryDetectionResult> {
const isWindows = process.platform === 'win32';
// First try direct file probing of known installation paths
// This is more reliable than which/where in packaged Electron apps
if (isWindows) {
const probedPath = await probeWindowsPaths(binaryName);
if (probedPath) {
return { exists: true, path: probedPath };
}
logger.debug(`Direct probe failed for ${binaryName}, falling back to where`, LOG_CONTEXT);
} else {
// macOS/Linux: probe known paths first
const probedPath = await probeUnixPaths(binaryName);
if (probedPath) {
return { exists: true, path: probedPath };
}
logger.debug(`Direct probe failed for ${binaryName}, falling back to which`, LOG_CONTEXT);
}
try {
// Use 'which' on Unix-like systems, 'where' on Windows
const command = isWindows ? 'where' : 'which';
// Use expanded PATH to find binaries in common installation locations
// This is critical for packaged Electron apps which don't inherit shell env
const env = getExpandedEnv();
const result = await execFileNoThrow(command, [binaryName], undefined, env);
if (result.exitCode === 0 && result.stdout.trim()) {
// Get all matches (Windows 'where' can return multiple)
// Handle both Unix (\n) and Windows (\r\n) line endings
const matches = result.stdout
.trim()
.split(/\r?\n/)
.map((p) => p.trim())
.filter((p) => p);
if (process.platform === 'win32' && matches.length > 0) {
// On Windows, prefer .exe over .cmd over extensionless
// This helps with proper execution handling
const exeMatch = matches.find((p) => p.toLowerCase().endsWith('.exe'));
const cmdMatch = matches.find((p) => p.toLowerCase().endsWith('.cmd'));
// Return the best match: .exe > .cmd > first result
let bestMatch = exeMatch || cmdMatch || matches[0];
// If the first match doesn't have an extension, check if .cmd or .exe version exists
// This handles cases where 'where' returns a path without extension
if (
!bestMatch.toLowerCase().endsWith('.exe') &&
!bestMatch.toLowerCase().endsWith('.cmd')
) {
const cmdPath = bestMatch + '.cmd';
const exePath = bestMatch + '.exe';
// Check if the .exe or .cmd version exists
try {
await fs.promises.access(exePath, fs.constants.F_OK);
bestMatch = exePath;
logger.debug(`Found .exe version of ${binaryName}`, LOG_CONTEXT, {
path: exePath,
});
} catch {
try {
await fs.promises.access(cmdPath, fs.constants.F_OK);
bestMatch = cmdPath;
logger.debug(`Found .cmd version of ${binaryName}`, LOG_CONTEXT, {
path: cmdPath,
});
} catch {
// Neither .exe nor .cmd exists, use the original path
}
}
}
logger.debug(`Windows binary detection for ${binaryName}`, LOG_CONTEXT, {
allMatches: matches,
selectedMatch: bestMatch,
isCmd: bestMatch.toLowerCase().endsWith('.cmd'),
isExe: bestMatch.toLowerCase().endsWith('.exe'),
});
return {
exists: true,
path: bestMatch,
};
}
return {
exists: true,
path: matches[0], // First match for Unix
};
}
return { exists: false };
} catch {
return { exists: false };
}
}