mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
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:
253
src/__tests__/main/agent-definitions.test.ts
Normal file
253
src/__tests__/main/agent-definitions.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
452
src/__tests__/main/path-prober.test.ts
Normal file
452
src/__tests__/main/path-prober.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
221
src/main/agent-definitions.ts
Normal file
221
src/main/agent-definitions.ts
Normal 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);
|
||||
}
|
||||
@@ -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
489
src/main/path-prober.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user