Skip to content

Commit 209c892

Browse files
khaliqgantclaude
andcommitted
test: add CLI resolution tests for cursor-agent/agent detection
Extract CLI resolution logic into separate testable module: - commandExists(): checks if CLI command is in PATH - detectCursorCli(): auto-detects 'agent' vs 'cursor-agent' - resolveCli(): maps provider names to actual CLI commands 23 unit tests covering: - Command existence checking (cross-platform) - Cursor CLI detection with caching - CLI resolution for cursor, google->gemini mappings - Integration scenarios for older/newer Cursor versions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 7d1d07b commit 209c892

File tree

4 files changed

+336
-82
lines changed

4 files changed

+336
-82
lines changed
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
/**
2+
* Unit tests for CLI Resolution Utilities
3+
*
4+
* Tests the detection and resolution of CLI commands for different providers,
5+
* particularly the Cursor CLI which has two names: 'agent' (newer) and 'cursor-agent' (older).
6+
*/
7+
8+
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
9+
import { execSync } from 'node:child_process';
10+
import {
11+
commandExists,
12+
detectCursorCli,
13+
resolveCli,
14+
resetCursorCliCache,
15+
CLI_COMMAND_MAP,
16+
} from './cli-resolution.js';
17+
18+
// Mock child_process module
19+
vi.mock('node:child_process', () => ({
20+
execSync: vi.fn(),
21+
}));
22+
23+
const mockedExecSync = vi.mocked(execSync);
24+
25+
describe('CLI Resolution', () => {
26+
beforeEach(() => {
27+
vi.clearAllMocks();
28+
resetCursorCliCache();
29+
});
30+
31+
afterEach(() => {
32+
vi.restoreAllMocks();
33+
});
34+
35+
describe('commandExists', () => {
36+
it('returns true when command exists', () => {
37+
mockedExecSync.mockReturnValue(Buffer.from('/usr/bin/agent'));
38+
expect(commandExists('agent')).toBe(true);
39+
expect(mockedExecSync).toHaveBeenCalledWith('which agent', { stdio: 'ignore' });
40+
});
41+
42+
it('returns false when command does not exist', () => {
43+
mockedExecSync.mockImplementation(() => {
44+
throw new Error('Command not found');
45+
});
46+
expect(commandExists('nonexistent-cmd')).toBe(false);
47+
});
48+
49+
it('uses "where" on Windows', () => {
50+
const originalPlatform = process.platform;
51+
Object.defineProperty(process, 'platform', { value: 'win32' });
52+
53+
mockedExecSync.mockReturnValue(Buffer.from('C:\\Path\\agent.exe'));
54+
commandExists('agent');
55+
expect(mockedExecSync).toHaveBeenCalledWith('where agent', { stdio: 'ignore' });
56+
57+
Object.defineProperty(process, 'platform', { value: originalPlatform });
58+
});
59+
});
60+
61+
describe('detectCursorCli', () => {
62+
it('returns "agent" when agent command exists', () => {
63+
mockedExecSync.mockReturnValue(Buffer.from('/usr/bin/agent'));
64+
expect(detectCursorCli()).toBe('agent');
65+
});
66+
67+
it('returns "cursor-agent" when only cursor-agent exists', () => {
68+
mockedExecSync.mockImplementation((cmd: string) => {
69+
if (cmd === 'which agent') {
70+
throw new Error('not found');
71+
}
72+
return Buffer.from('/usr/bin/cursor-agent');
73+
});
74+
expect(detectCursorCli()).toBe('cursor-agent');
75+
});
76+
77+
it('returns null when neither command exists', () => {
78+
mockedExecSync.mockImplementation(() => {
79+
throw new Error('not found');
80+
});
81+
expect(detectCursorCli()).toBeNull();
82+
});
83+
84+
it('caches the detected CLI', () => {
85+
mockedExecSync.mockReturnValue(Buffer.from('/usr/bin/agent'));
86+
87+
// First call detects
88+
expect(detectCursorCli()).toBe('agent');
89+
expect(mockedExecSync).toHaveBeenCalledTimes(1);
90+
91+
// Second call uses cache
92+
expect(detectCursorCli()).toBe('agent');
93+
expect(mockedExecSync).toHaveBeenCalledTimes(1); // Still 1
94+
});
95+
96+
it('cache can be reset', () => {
97+
mockedExecSync.mockReturnValue(Buffer.from('/usr/bin/agent'));
98+
99+
detectCursorCli();
100+
expect(mockedExecSync).toHaveBeenCalledTimes(1);
101+
102+
resetCursorCliCache();
103+
104+
detectCursorCli();
105+
expect(mockedExecSync).toHaveBeenCalledTimes(2);
106+
});
107+
});
108+
109+
describe('resolveCli', () => {
110+
it('resolves "cursor" to detected CLI (agent)', () => {
111+
mockedExecSync.mockReturnValue(Buffer.from('/usr/bin/agent'));
112+
expect(resolveCli('cursor')).toBe('agent');
113+
});
114+
115+
it('resolves "cursor" to detected CLI (cursor-agent)', () => {
116+
mockedExecSync.mockImplementation((cmd: string) => {
117+
if (cmd === 'which agent') {
118+
throw new Error('not found');
119+
}
120+
return Buffer.from('/usr/bin/cursor-agent');
121+
});
122+
expect(resolveCli('cursor')).toBe('cursor-agent');
123+
});
124+
125+
it('resolves "cursor-agent" input to detected CLI', () => {
126+
mockedExecSync.mockReturnValue(Buffer.from('/usr/bin/agent'));
127+
expect(resolveCli('cursor-agent')).toBe('agent');
128+
});
129+
130+
it('falls back to "agent" when cursor CLI not detected', () => {
131+
mockedExecSync.mockImplementation(() => {
132+
throw new Error('not found');
133+
});
134+
expect(resolveCli('cursor')).toBe('agent');
135+
});
136+
137+
it('is case insensitive for cursor', () => {
138+
mockedExecSync.mockReturnValue(Buffer.from('/usr/bin/agent'));
139+
expect(resolveCli('CURSOR')).toBe('agent');
140+
expect(resolveCli('Cursor')).toBe('agent');
141+
});
142+
143+
it('resolves "google" to "gemini"', () => {
144+
expect(resolveCli('google')).toBe('gemini');
145+
expect(resolveCli('Google')).toBe('gemini');
146+
});
147+
148+
it('returns other commands unchanged', () => {
149+
expect(resolveCli('claude')).toBe('claude');
150+
expect(resolveCli('codex')).toBe('codex');
151+
expect(resolveCli('gemini')).toBe('gemini');
152+
expect(resolveCli('opencode')).toBe('opencode');
153+
});
154+
155+
it('preserves case for unknown commands', () => {
156+
expect(resolveCli('MyCustomCli')).toBe('MyCustomCli');
157+
});
158+
});
159+
160+
describe('CLI_COMMAND_MAP', () => {
161+
it('maps cursor to agent', () => {
162+
expect(CLI_COMMAND_MAP['cursor']).toBe('agent');
163+
});
164+
165+
it('maps cursor-agent to agent', () => {
166+
expect(CLI_COMMAND_MAP['cursor-agent']).toBe('agent');
167+
});
168+
169+
it('maps google to gemini', () => {
170+
expect(CLI_COMMAND_MAP['google']).toBe('gemini');
171+
});
172+
});
173+
});
174+
175+
describe('CLI Resolution Integration', () => {
176+
describe('Cursor CLI scenarios', () => {
177+
beforeEach(() => {
178+
vi.clearAllMocks();
179+
resetCursorCliCache();
180+
});
181+
182+
it('handles user with newer Cursor (agent available)', () => {
183+
// User has Cursor v0.50+ with "agent" CLI
184+
mockedExecSync.mockReturnValue(Buffer.from('/usr/bin/agent'));
185+
186+
const resolved = resolveCli('cursor');
187+
expect(resolved).toBe('agent');
188+
});
189+
190+
it('handles user with older Cursor (cursor-agent available)', () => {
191+
// User has older Cursor with "cursor-agent" CLI
192+
mockedExecSync.mockImplementation((cmd: string) => {
193+
if (cmd === 'which agent') {
194+
throw new Error('not found');
195+
}
196+
return Buffer.from('/usr/bin/cursor-agent');
197+
});
198+
199+
const resolved = resolveCli('cursor');
200+
expect(resolved).toBe('cursor-agent');
201+
});
202+
203+
it('handles team spawn request with cursor CLI', () => {
204+
// Lead agent spawns worker with "cursor" - should resolve to available CLI
205+
mockedExecSync.mockReturnValue(Buffer.from('/usr/bin/agent'));
206+
207+
// Simulate spawn request parsing
208+
const cli = 'cursor';
209+
const cliParts = cli.split(' ');
210+
const rawCommand = cliParts[0];
211+
const resolved = resolveCli(rawCommand);
212+
213+
expect(resolved).toBe('agent');
214+
});
215+
216+
it('handles explicit cursor-agent in spawn request', () => {
217+
// User explicitly requests cursor-agent - should still check availability
218+
mockedExecSync.mockReturnValue(Buffer.from('/usr/bin/agent'));
219+
220+
const resolved = resolveCli('cursor-agent');
221+
// Even when asking for cursor-agent, if agent is available, use agent
222+
expect(resolved).toBe('agent');
223+
});
224+
});
225+
});
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/**
2+
* CLI Resolution Utilities
3+
*
4+
* Handles mapping and detection of CLI commands for different providers.
5+
* Cursor has two CLI names: 'agent' (newer) and 'cursor-agent' (older).
6+
*/
7+
8+
import { execSync } from 'node:child_process';
9+
import { createLogger } from '@agent-relay/utils/logger';
10+
11+
const log = createLogger('cli-resolution');
12+
13+
/**
14+
* Check if a command exists in PATH
15+
*/
16+
export function commandExists(cmd: string): boolean {
17+
try {
18+
const whichCmd = process.platform === 'win32' ? 'where' : 'which';
19+
execSync(`${whichCmd} ${cmd}`, { stdio: 'ignore' });
20+
return true;
21+
} catch {
22+
return false;
23+
}
24+
}
25+
26+
// Cache for detected Cursor CLI command
27+
let detectedCursorCli: string | null = null;
28+
29+
/**
30+
* Reset the Cursor CLI detection cache.
31+
* Useful for testing.
32+
*/
33+
export function resetCursorCliCache(): void {
34+
detectedCursorCli = null;
35+
}
36+
37+
/**
38+
* Detect which Cursor CLI command is available.
39+
* Newer versions use 'agent', older versions use 'cursor-agent'.
40+
* Returns null if neither is found.
41+
*/
42+
export function detectCursorCli(): string | null {
43+
if (detectedCursorCli !== null) {
44+
return detectedCursorCli;
45+
}
46+
47+
// Try newer 'agent' command first
48+
if (commandExists('agent')) {
49+
detectedCursorCli = 'agent';
50+
log.debug('Detected Cursor CLI: agent (newer version)');
51+
return 'agent';
52+
}
53+
54+
// Fall back to older 'cursor-agent' command
55+
if (commandExists('cursor-agent')) {
56+
detectedCursorCli = 'cursor-agent';
57+
log.debug('Detected Cursor CLI: cursor-agent (older version)');
58+
return 'cursor-agent';
59+
}
60+
61+
log.debug('Cursor CLI not found (neither agent nor cursor-agent)');
62+
return null;
63+
}
64+
65+
/**
66+
* Resolve CLI command for a provider.
67+
* For cursor, detects whether 'agent' or 'cursor-agent' is available.
68+
*/
69+
export function resolveCli(rawCommand: string): string {
70+
const cmdLower = rawCommand.toLowerCase();
71+
72+
// Handle cursor specially - detect which CLI is installed
73+
if (cmdLower === 'cursor' || cmdLower === 'cursor-agent') {
74+
const cursorCli = detectCursorCli();
75+
if (cursorCli) {
76+
return cursorCli;
77+
}
78+
// Fall back to 'agent' if detection fails (let it fail at spawn time)
79+
return 'agent';
80+
}
81+
82+
// Handle other mappings
83+
if (cmdLower === 'google') {
84+
return 'gemini';
85+
}
86+
87+
// Return as-is for other commands
88+
return rawCommand;
89+
}
90+
91+
/**
92+
* CLI command mapping for providers (kept for reference, resolveCli handles logic)
93+
* Maps provider names to actual CLI command names
94+
*/
95+
export const CLI_COMMAND_MAP: Record<string, string> = {
96+
cursor: 'agent', // Cursor CLI installs as 'agent' (newer versions)
97+
'cursor-agent': 'agent', // Cursor CLI older name, also maps to 'agent'
98+
google: 'gemini', // Google provider uses 'gemini' CLI
99+
// Other providers use their name as the command (claude, codex, etc.)
100+
};

packages/bridge/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,15 @@ export {
1313
type ShadowCliSelection,
1414
} from './shadow-cli.js';
1515

16+
// CLI resolution utilities
17+
export {
18+
commandExists,
19+
detectCursorCli,
20+
resolveCli,
21+
resetCursorCliCache,
22+
CLI_COMMAND_MAP,
23+
} from './cli-resolution.js';
24+
1625
// Agent spawner
1726
export {
1827
AgentSpawner,

0 commit comments

Comments
 (0)