| |
| |
| |
| |
| |
|
|
| import { spawn, execSync } from 'child_process'; |
| import * as fs from 'fs'; |
| import * as path from 'path'; |
| import * as os from 'os'; |
|
|
| export interface CliInfo { |
| name: string; |
| command: string; |
| version?: string; |
| path?: string; |
| installed: boolean; |
| authenticated: boolean; |
| authMethod: 'cli' | 'api_key' | 'none'; |
| platform?: string; |
| architectures?: string[]; |
| } |
|
|
| export interface CliDetectionOptions { |
| timeout?: number; |
| includeWsl?: boolean; |
| wslDistribution?: string; |
| } |
|
|
| export interface CliDetectionResult { |
| cli: CliInfo; |
| detected: boolean; |
| issues: string[]; |
| } |
|
|
| export interface UnifiedCliDetection { |
| claude?: CliDetectionResult; |
| codex?: CliDetectionResult; |
| cursor?: CliDetectionResult; |
| } |
|
|
| |
| |
| |
| const CLI_CONFIGS = { |
| claude: { |
| name: 'Claude CLI', |
| commands: ['claude'], |
| versionArgs: ['--version'], |
| installCommands: { |
| darwin: 'brew install anthropics/claude/claude', |
| linux: 'curl -fsSL https://claude.ai/install.sh | sh', |
| win32: 'iwr https://claude.ai/install.ps1 -UseBasicParsing | iex', |
| }, |
| }, |
| codex: { |
| name: 'Codex CLI', |
| commands: ['codex', 'openai'], |
| versionArgs: ['--version'], |
| installCommands: { |
| darwin: 'npm install -g @openai/codex-cli', |
| linux: 'npm install -g @openai/codex-cli', |
| win32: 'npm install -g @openai/codex-cli', |
| }, |
| }, |
| cursor: { |
| name: 'Cursor CLI', |
| commands: ['cursor-agent', 'cursor'], |
| versionArgs: ['--version'], |
| installCommands: { |
| darwin: 'brew install cursor/cursor/cursor-agent', |
| linux: 'curl -fsSL https://cursor.sh/install.sh | sh', |
| win32: 'iwr https://cursor.sh/install.ps1 -UseBasicParsing | iex', |
| }, |
| }, |
| } as const; |
|
|
| |
| |
| |
| export async function detectCli( |
| provider: keyof typeof CLI_CONFIGS, |
| options: CliDetectionOptions = {} |
| ): Promise<CliDetectionResult> { |
| const config = CLI_CONFIGS[provider]; |
| const { timeout = 5000 } = options; |
| const issues: string[] = []; |
|
|
| const cliInfo: CliInfo = { |
| name: config.name, |
| command: '', |
| installed: false, |
| authenticated: false, |
| authMethod: 'none', |
| }; |
|
|
| try { |
| |
| const command = await findCommand([...config.commands]); |
| if (command) { |
| cliInfo.command = command; |
| } |
|
|
| if (!cliInfo.command) { |
| issues.push(`${config.name} not found in PATH`); |
| return { cli: cliInfo, detected: false, issues }; |
| } |
|
|
| cliInfo.path = cliInfo.command; |
| cliInfo.installed = true; |
|
|
| |
| try { |
| cliInfo.version = await getCliVersion(cliInfo.command, [...config.versionArgs], timeout); |
| } catch (error) { |
| issues.push(`Failed to get ${config.name} version: ${error}`); |
| } |
|
|
| |
| cliInfo.authMethod = await checkCliAuth(provider, cliInfo.command); |
| cliInfo.authenticated = cliInfo.authMethod !== 'none'; |
|
|
| return { cli: cliInfo, detected: true, issues }; |
| } catch (error) { |
| issues.push(`Error detecting ${config.name}: ${error}`); |
| return { cli: cliInfo, detected: false, issues }; |
| } |
| } |
|
|
| |
| |
| |
| export async function detectAllCLis( |
| options: CliDetectionOptions = {} |
| ): Promise<UnifiedCliDetection> { |
| const results: UnifiedCliDetection = {}; |
|
|
| |
| const providers = Object.keys(CLI_CONFIGS) as Array<keyof typeof CLI_CONFIGS>; |
| const detectionPromises = providers.map(async (provider) => { |
| const result = await detectCli(provider, options); |
| return { provider, result }; |
| }); |
|
|
| const detections = await Promise.all(detectionPromises); |
|
|
| for (const { provider, result } of detections) { |
| results[provider] = result; |
| } |
|
|
| return results; |
| } |
|
|
| |
| |
| |
| export async function findCommand(commands: string[]): Promise<string | null> { |
| for (const command of commands) { |
| try { |
| const whichCommand = process.platform === 'win32' ? 'where' : 'which'; |
| const result = execSync(`${whichCommand} ${command}`, { |
| encoding: 'utf8', |
| timeout: 2000, |
| }).trim(); |
|
|
| if (result) { |
| return result.split('\n')[0]; |
| } |
| } catch { |
| |
| } |
| } |
| return null; |
| } |
|
|
| |
| |
| |
| export async function getCliVersion( |
| command: string, |
| args: string[], |
| timeout: number = 5000 |
| ): Promise<string> { |
| return new Promise((resolve, reject) => { |
| const child = spawn(command, args, { |
| stdio: 'pipe', |
| timeout, |
| }); |
|
|
| let stdout = ''; |
| let stderr = ''; |
|
|
| child.stdout?.on('data', (data) => { |
| stdout += data.toString(); |
| }); |
|
|
| child.stderr?.on('data', (data) => { |
| stderr += data.toString(); |
| }); |
|
|
| child.on('close', (code) => { |
| if (code === 0 && stdout) { |
| resolve(stdout.trim()); |
| } else if (stderr) { |
| reject(stderr.trim()); |
| } else { |
| reject(`Command exited with code ${code}`); |
| } |
| }); |
|
|
| child.on('error', reject); |
| }); |
| } |
|
|
| |
| |
| |
| export async function checkCliAuth( |
| provider: keyof typeof CLI_CONFIGS, |
| command: string |
| ): Promise<'cli' | 'api_key' | 'none'> { |
| try { |
| switch (provider) { |
| case 'claude': |
| return await checkClaudeAuth(command); |
| case 'codex': |
| return await checkCodexAuth(command); |
| case 'cursor': |
| return await checkCursorAuth(command); |
| default: |
| return 'none'; |
| } |
| } catch { |
| return 'none'; |
| } |
| } |
|
|
| |
| |
| |
| async function checkClaudeAuth(command: string): Promise<'cli' | 'api_key' | 'none'> { |
| try { |
| |
| if (process.env.ANTHROPIC_API_KEY) { |
| return 'api_key'; |
| } |
|
|
| |
| const result = await getCliVersion(command, ['--version'], 3000); |
| if (result) { |
| return 'cli'; |
| } |
| } catch { |
| |
| } |
|
|
| |
| return new Promise((resolve) => { |
| const child = spawn(command, ['whoami'], { |
| stdio: 'pipe', |
| timeout: 3000, |
| }); |
|
|
| let stdout = ''; |
| let stderr = ''; |
|
|
| child.stdout?.on('data', (data) => { |
| stdout += data.toString(); |
| }); |
|
|
| child.stderr?.on('data', (data) => { |
| stderr += data.toString(); |
| }); |
|
|
| child.on('close', (code) => { |
| if (code === 0 && stdout && !stderr.includes('not authenticated')) { |
| resolve('cli'); |
| } else { |
| resolve('none'); |
| } |
| }); |
|
|
| child.on('error', () => { |
| resolve('none'); |
| }); |
| }); |
| } |
|
|
| |
| |
| |
| async function checkCodexAuth(command: string): Promise<'cli' | 'api_key' | 'none'> { |
| |
| if (process.env.OPENAI_API_KEY) { |
| return 'api_key'; |
| } |
|
|
| try { |
| |
| const result = await getCliVersion(command, ['--version'], 3000); |
| if (result) { |
| return 'cli'; |
| } |
| } catch { |
| |
| } |
|
|
| return 'none'; |
| } |
|
|
| |
| |
| |
| async function checkCursorAuth(command: string): Promise<'cli' | 'api_key' | 'none'> { |
| |
| if (process.env.CURSOR_API_KEY) { |
| return 'api_key'; |
| } |
|
|
| |
| const credentialPaths = [ |
| path.join(os.homedir(), '.cursor', 'credentials.json'), |
| path.join(os.homedir(), '.config', 'cursor', 'credentials.json'), |
| path.join(os.homedir(), '.cursor', 'auth.json'), |
| path.join(os.homedir(), '.config', 'cursor', 'auth.json'), |
| ]; |
|
|
| for (const credPath of credentialPaths) { |
| try { |
| if (fs.existsSync(credPath)) { |
| const content = fs.readFileSync(credPath, 'utf8'); |
| const creds = JSON.parse(content); |
| if (creds.accessToken || creds.token || creds.apiKey) { |
| return 'cli'; |
| } |
| } |
| } catch { |
| |
| } |
| } |
|
|
| |
| try { |
| const result = await getCliVersion(command, ['--version'], 3000); |
| if (result) { |
| return 'cli'; |
| } |
| } catch { |
| |
| } |
|
|
| return 'none'; |
| } |
|
|
| |
| |
| |
| export function getInstallInstructions( |
| provider: keyof typeof CLI_CONFIGS, |
| platform: NodeJS.Platform = process.platform |
| ): string { |
| const config = CLI_CONFIGS[provider]; |
| const command = config.installCommands[platform as keyof typeof config.installCommands]; |
|
|
| if (!command) { |
| return `No installation instructions available for ${provider} on ${platform}`; |
| } |
|
|
| return command; |
| } |
|
|
| |
| |
| |
| export function getPlatformCliPaths(provider: keyof typeof CLI_CONFIGS): string[] { |
| const config = CLI_CONFIGS[provider]; |
| const platform = process.platform; |
|
|
| switch (platform) { |
| case 'darwin': |
| return [ |
| `/usr/local/bin/${config.commands[0]}`, |
| `/opt/homebrew/bin/${config.commands[0]}`, |
| path.join(os.homedir(), '.local', 'bin', config.commands[0]), |
| ]; |
|
|
| case 'linux': |
| return [ |
| `/usr/bin/${config.commands[0]}`, |
| `/usr/local/bin/${config.commands[0]}`, |
| path.join(os.homedir(), '.local', 'bin', config.commands[0]), |
| path.join(os.homedir(), '.npm', 'global', 'bin', config.commands[0]), |
| ]; |
|
|
| case 'win32': |
| return [ |
| path.join( |
| os.homedir(), |
| 'AppData', |
| 'Local', |
| 'Programs', |
| config.commands[0], |
| `${config.commands[0]}.exe` |
| ), |
| path.join(process.env.ProgramFiles || '', config.commands[0], `${config.commands[0]}.exe`), |
| path.join( |
| process.env.ProgramFiles || '', |
| config.commands[0], |
| 'bin', |
| `${config.commands[0]}.exe` |
| ), |
| ]; |
|
|
| default: |
| return []; |
| } |
| } |
|
|
| |
| |
| |
| export function validateCliInstallation(cliInfo: CliInfo): { |
| valid: boolean; |
| issues: string[]; |
| } { |
| const issues: string[] = []; |
|
|
| if (!cliInfo.installed) { |
| issues.push('CLI is not installed'); |
| } |
|
|
| if (cliInfo.installed && !cliInfo.version) { |
| issues.push('Could not determine CLI version'); |
| } |
|
|
| if (cliInfo.installed && cliInfo.authMethod === 'none') { |
| issues.push('CLI is not authenticated'); |
| } |
|
|
| return { |
| valid: issues.length === 0, |
| issues, |
| }; |
| } |
|
|