mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
feat: Add WSL support for Cursor CLI on Windows
- Add reusable WSL utilities in @automaker/platform (wsl.ts): - isWslAvailable() - Check if WSL is available on Windows - findCliInWsl() - Find CLI tools in WSL, tries multiple distributions - execInWsl() - Execute commands in WSL - createWslCommand() - Create spawn-compatible command/args for WSL - windowsToWslPath/wslToWindowsPath - Path conversion utilities - getWslDistributions() - List available WSL distributions - Update CursorProvider to use WSL on Windows: - Detect cursor-agent in WSL distributions (prioritizes Ubuntu) - Use full path to wsl.exe for spawn() compatibility - Pass --cd flag for working directory inside WSL - Store and use WSL distribution for all commands - Show "(WSL:Ubuntu) /path" in installation status - Add 'wsl' to InstallationStatus.method type - Fix bugs: - Fix ternary in settings-view.tsx that always returned 'claude' - Fix findIndex -1 handling in WSL command construction - Remove 'gpt-5.2' from unknown models test (now valid Cursor model) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,15 @@ import {
|
|||||||
CURSOR_MODEL_MAP,
|
CURSOR_MODEL_MAP,
|
||||||
} from '@automaker/types';
|
} from '@automaker/types';
|
||||||
import { createLogger, isAbortError } from '@automaker/utils';
|
import { createLogger, isAbortError } from '@automaker/utils';
|
||||||
import { spawnJSONLProcess, type SubprocessOptions } from '@automaker/platform';
|
import {
|
||||||
|
spawnJSONLProcess,
|
||||||
|
type SubprocessOptions,
|
||||||
|
isWslAvailable,
|
||||||
|
findCliInWsl,
|
||||||
|
createWslCommand,
|
||||||
|
execInWsl,
|
||||||
|
windowsToWslPath,
|
||||||
|
} from '@automaker/platform';
|
||||||
|
|
||||||
// Create logger for this module
|
// Create logger for this module
|
||||||
const logger = createLogger('CursorProvider');
|
const logger = createLogger('CursorProvider');
|
||||||
@@ -69,6 +77,10 @@ export class CursorProvider extends BaseProvider {
|
|||||||
* The install script creates versioned folders like:
|
* The install script creates versioned folders like:
|
||||||
* ~/.local/share/cursor-agent/versions/2025.12.17-996666f/cursor-agent
|
* ~/.local/share/cursor-agent/versions/2025.12.17-996666f/cursor-agent
|
||||||
* And symlinks to ~/.local/bin/cursor-agent
|
* And symlinks to ~/.local/bin/cursor-agent
|
||||||
|
*
|
||||||
|
* Windows:
|
||||||
|
* - cursor-agent CLI only supports Linux/macOS, NOT native Windows
|
||||||
|
* - On Windows, users must install in WSL and we invoke via wsl.exe
|
||||||
*/
|
*/
|
||||||
private static COMMON_PATHS: Record<string, string[]> = {
|
private static COMMON_PATHS: Record<string, string[]> = {
|
||||||
linux: [
|
linux: [
|
||||||
@@ -79,11 +91,8 @@ export class CursorProvider extends BaseProvider {
|
|||||||
path.join(os.homedir(), '.local/bin/cursor-agent'), // Primary symlink location
|
path.join(os.homedir(), '.local/bin/cursor-agent'), // Primary symlink location
|
||||||
'/usr/local/bin/cursor-agent',
|
'/usr/local/bin/cursor-agent',
|
||||||
],
|
],
|
||||||
win32: [
|
// Windows paths are not used - we check for WSL installation instead
|
||||||
path.join(os.homedir(), 'AppData/Local/Programs/cursor-agent/cursor-agent.exe'),
|
win32: [],
|
||||||
path.join(os.homedir(), '.local/bin/cursor-agent.exe'),
|
|
||||||
'C:\\Program Files\\cursor-agent\\cursor-agent.exe',
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Version data directory where cursor-agent stores versions
|
// Version data directory where cursor-agent stores versions
|
||||||
@@ -91,9 +100,14 @@ export class CursorProvider extends BaseProvider {
|
|||||||
|
|
||||||
private cliPath: string | null = null;
|
private cliPath: string | null = null;
|
||||||
|
|
||||||
|
// WSL execution mode for Windows
|
||||||
|
private useWsl: boolean = false;
|
||||||
|
private wslCliPath: string | null = null;
|
||||||
|
private wslDistribution: string | undefined = undefined;
|
||||||
|
|
||||||
constructor(config: ProviderConfig = {}) {
|
constructor(config: ProviderConfig = {}) {
|
||||||
super(config);
|
super(config);
|
||||||
this.cliPath = config.cliPath || this.findCliPath();
|
this.findCliPath();
|
||||||
}
|
}
|
||||||
|
|
||||||
getName(): string {
|
getName(): string {
|
||||||
@@ -102,15 +116,45 @@ export class CursorProvider extends BaseProvider {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Find cursor-agent CLI in PATH or common installation locations
|
* Find cursor-agent CLI in PATH or common installation locations
|
||||||
|
*
|
||||||
|
* On Windows, uses WSL utilities from @automaker/platform since
|
||||||
|
* cursor-agent CLI only supports Linux/macOS natively.
|
||||||
*/
|
*/
|
||||||
private findCliPath(): string | null {
|
private findCliPath(): void {
|
||||||
// Try 'which' / 'where' first
|
const wslLogger = (msg: string) => logger.debug(msg);
|
||||||
|
|
||||||
|
// On Windows, we need to use WSL (cursor-agent has no native Windows build)
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
if (isWslAvailable({ logger: wslLogger })) {
|
||||||
|
const wslResult = findCliInWsl('cursor-agent', { logger: wslLogger });
|
||||||
|
if (wslResult) {
|
||||||
|
this.useWsl = true;
|
||||||
|
this.wslCliPath = wslResult.wslPath;
|
||||||
|
this.wslDistribution = wslResult.distribution;
|
||||||
|
this.cliPath = 'wsl.exe'; // We'll use wsl.exe to invoke
|
||||||
|
logger.debug(
|
||||||
|
`Using cursor-agent via WSL (${wslResult.distribution || 'default'}): ${wslResult.wslPath}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
'cursor-agent CLI not found (WSL not available or cursor-agent not installed in WSL)'
|
||||||
|
);
|
||||||
|
this.cliPath = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Linux/macOS - direct execution
|
||||||
|
// Try 'which' first
|
||||||
try {
|
try {
|
||||||
const cmd = process.platform === 'win32' ? 'where cursor-agent' : 'which cursor-agent';
|
const result = execSync('which cursor-agent', { encoding: 'utf8', timeout: 5000 })
|
||||||
const result = execSync(cmd, { encoding: 'utf8', timeout: 5000 }).trim().split('\n')[0];
|
.trim()
|
||||||
|
.split('\n')[0];
|
||||||
if (result && fs.existsSync(result)) {
|
if (result && fs.existsSync(result)) {
|
||||||
logger.debug(`Found cursor-agent in PATH: ${result}`);
|
logger.debug(`Found cursor-agent in PATH: ${result}`);
|
||||||
return result;
|
this.cliPath = result;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Not in PATH
|
// Not in PATH
|
||||||
@@ -123,7 +167,8 @@ export class CursorProvider extends BaseProvider {
|
|||||||
for (const p of platformPaths) {
|
for (const p of platformPaths) {
|
||||||
if (fs.existsSync(p)) {
|
if (fs.existsSync(p)) {
|
||||||
logger.debug(`Found cursor-agent at: ${p}`);
|
logger.debug(`Found cursor-agent at: ${p}`);
|
||||||
return p;
|
this.cliPath = p;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,11 +182,11 @@ export class CursorProvider extends BaseProvider {
|
|||||||
.reverse(); // Most recent first
|
.reverse(); // Most recent first
|
||||||
|
|
||||||
for (const version of versions) {
|
for (const version of versions) {
|
||||||
const binaryName = platform === 'win32' ? 'cursor-agent.exe' : 'cursor-agent';
|
const versionPath = path.join(CursorProvider.VERSIONS_DIR, version, 'cursor-agent');
|
||||||
const versionPath = path.join(CursorProvider.VERSIONS_DIR, version, binaryName);
|
|
||||||
if (fs.existsSync(versionPath)) {
|
if (fs.existsSync(versionPath)) {
|
||||||
logger.debug(`Found cursor-agent version ${version} at: ${versionPath}`);
|
logger.debug(`Found cursor-agent version ${version} at: ${versionPath}`);
|
||||||
return versionPath;
|
this.cliPath = versionPath;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -150,7 +195,7 @@ export class CursorProvider extends BaseProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
logger.debug('cursor-agent CLI not found');
|
logger.debug('cursor-agent CLI not found');
|
||||||
return null;
|
this.cliPath = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -167,6 +212,14 @@ export class CursorProvider extends BaseProvider {
|
|||||||
if (!this.cliPath) return null;
|
if (!this.cliPath) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (this.useWsl && this.wslCliPath) {
|
||||||
|
// Execute via WSL using utility from @automaker/platform
|
||||||
|
const result = execInWsl(`${this.wslCliPath} --version`, {
|
||||||
|
timeout: 5000,
|
||||||
|
distribution: this.wslDistribution,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
const result = execSync(`"${this.cliPath}" --version`, {
|
const result = execSync(`"${this.cliPath}" --version`, {
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
timeout: 5000,
|
timeout: 5000,
|
||||||
@@ -190,7 +243,43 @@ export class CursorProvider extends BaseProvider {
|
|||||||
return { authenticated: true, method: 'api_key' };
|
return { authenticated: true, method: 'api_key' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for credentials file (location may vary)
|
// For WSL mode, check credentials inside WSL
|
||||||
|
if (this.useWsl && this.wslCliPath) {
|
||||||
|
const wslOpts = { timeout: 5000, distribution: this.wslDistribution };
|
||||||
|
|
||||||
|
// Check for credentials file inside WSL (use $HOME for proper expansion)
|
||||||
|
const wslCredPaths = [
|
||||||
|
'$HOME/.cursor/credentials.json',
|
||||||
|
'$HOME/.config/cursor/credentials.json',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const credPath of wslCredPaths) {
|
||||||
|
const content = execInWsl(`sh -c "cat ${credPath} 2>/dev/null || echo ''"`, wslOpts);
|
||||||
|
if (content && content.trim()) {
|
||||||
|
try {
|
||||||
|
const creds = JSON.parse(content);
|
||||||
|
if (creds.accessToken || creds.token) {
|
||||||
|
return { authenticated: true, method: 'login', hasCredentialsFile: true };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid credentials file
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try running --version to check if CLI works
|
||||||
|
const versionResult = execInWsl(`${this.wslCliPath} --version`, {
|
||||||
|
timeout: 10000,
|
||||||
|
distribution: this.wslDistribution,
|
||||||
|
});
|
||||||
|
if (versionResult) {
|
||||||
|
return { authenticated: true, method: 'login' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { authenticated: false, method: 'none' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Native mode (Linux/macOS) - check local credentials
|
||||||
const credentialPaths = [
|
const credentialPaths = [
|
||||||
path.join(os.homedir(), '.cursor', 'credentials.json'),
|
path.join(os.homedir(), '.cursor', 'credentials.json'),
|
||||||
path.join(os.homedir(), '.config', 'cursor', 'credentials.json'),
|
path.join(os.homedir(), '.config', 'cursor', 'credentials.json'),
|
||||||
@@ -238,11 +327,17 @@ export class CursorProvider extends BaseProvider {
|
|||||||
const version = installed ? await this.getVersion() : undefined;
|
const version = installed ? await this.getVersion() : undefined;
|
||||||
const auth = await this.checkAuth();
|
const auth = await this.checkAuth();
|
||||||
|
|
||||||
|
// Determine the display path - for WSL, show the WSL path with distribution
|
||||||
|
const displayPath =
|
||||||
|
this.useWsl && this.wslCliPath
|
||||||
|
? `(WSL${this.wslDistribution ? `:${this.wslDistribution}` : ''}) ${this.wslCliPath}`
|
||||||
|
: this.cliPath || undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
installed,
|
installed,
|
||||||
version: version || undefined,
|
version: version || undefined,
|
||||||
path: this.cliPath || undefined,
|
path: displayPath,
|
||||||
method: 'cli',
|
method: this.useWsl ? 'wsl' : 'cli',
|
||||||
hasApiKey: !!process.env.CURSOR_API_KEY,
|
hasApiKey: !!process.env.CURSOR_API_KEY,
|
||||||
authenticated: auth.authenticated,
|
authenticated: auth.authenticated,
|
||||||
};
|
};
|
||||||
@@ -500,11 +595,17 @@ export class CursorProvider extends BaseProvider {
|
|||||||
*/
|
*/
|
||||||
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
async *executeQuery(options: ExecuteOptions): AsyncGenerator<ProviderMessage> {
|
||||||
if (!this.cliPath) {
|
if (!this.cliPath) {
|
||||||
|
// Provide platform-specific installation instructions
|
||||||
|
const installSuggestion =
|
||||||
|
process.platform === 'win32'
|
||||||
|
? 'cursor-agent requires WSL on Windows. Install WSL, then run in WSL: curl https://cursor.com/install -fsS | bash'
|
||||||
|
: 'Install with: curl https://cursor.com/install -fsS | bash';
|
||||||
|
|
||||||
throw this.createError(
|
throw this.createError(
|
||||||
CursorErrorCode.NOT_INSTALLED,
|
CursorErrorCode.NOT_INSTALLED,
|
||||||
'Cursor CLI is not installed',
|
'Cursor CLI is not installed',
|
||||||
true,
|
true,
|
||||||
'Install with: curl https://cursor.com/install -fsS | bash'
|
installSuggestion
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -532,8 +633,8 @@ export class CursorProvider extends BaseProvider {
|
|||||||
throw new Error('Invalid prompt format');
|
throw new Error('Invalid prompt format');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build CLI arguments
|
// Build CLI arguments for cursor-agent
|
||||||
const args: string[] = [
|
const cliArgs: string[] = [
|
||||||
'-p', // Print mode (non-interactive)
|
'-p', // Print mode (non-interactive)
|
||||||
'--force', // Allow file modifications
|
'--force', // Allow file modifications
|
||||||
'--output-format',
|
'--output-format',
|
||||||
@@ -543,13 +644,46 @@ export class CursorProvider extends BaseProvider {
|
|||||||
|
|
||||||
// Add model if not auto
|
// Add model if not auto
|
||||||
if (model !== 'auto') {
|
if (model !== 'auto') {
|
||||||
args.push('--model', model);
|
cliArgs.push('--model', model);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the prompt
|
// Add the prompt
|
||||||
args.push(promptText);
|
cliArgs.push(promptText);
|
||||||
|
|
||||||
logger.debug(`Executing: ${this.cliPath} ${args.slice(0, 6).join(' ')}...`);
|
// Determine command and args based on WSL mode
|
||||||
|
let command: string;
|
||||||
|
let args: string[];
|
||||||
|
let workingDir: string;
|
||||||
|
|
||||||
|
if (this.useWsl && this.wslCliPath) {
|
||||||
|
// Build WSL command with --cd flag to change directory inside WSL
|
||||||
|
const wslCmd = createWslCommand(this.wslCliPath, cliArgs, {
|
||||||
|
distribution: this.wslDistribution,
|
||||||
|
});
|
||||||
|
command = wslCmd.command;
|
||||||
|
const wslCwd = windowsToWslPath(cwd);
|
||||||
|
|
||||||
|
// Construct args with --cd flag inserted before the CLI path
|
||||||
|
// createWslCommand returns: ['-d', distro, cliPath, ...cliArgs] or [cliPath, ...cliArgs]
|
||||||
|
if (this.wslDistribution) {
|
||||||
|
// With distribution: wsl.exe -d <distro> --cd <path> <cli> <args>
|
||||||
|
args = ['-d', this.wslDistribution, '--cd', wslCwd, this.wslCliPath, ...cliArgs];
|
||||||
|
} else {
|
||||||
|
// Without distribution: wsl.exe --cd <path> <cli> <args>
|
||||||
|
args = ['--cd', wslCwd, this.wslCliPath, ...cliArgs];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep Windows path for spawn's cwd (spawn runs on Windows)
|
||||||
|
workingDir = cwd;
|
||||||
|
logger.debug(
|
||||||
|
`Executing via WSL (${this.wslDistribution || 'default'}): ${command} ${args.slice(0, 6).join(' ')}...`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
command = this.cliPath;
|
||||||
|
args = cliArgs;
|
||||||
|
workingDir = cwd;
|
||||||
|
logger.debug(`Executing: ${command} ${args.slice(0, 6).join(' ')}...`);
|
||||||
|
}
|
||||||
|
|
||||||
// Use spawnJSONLProcess from @automaker/platform for JSONL streaming
|
// Use spawnJSONLProcess from @automaker/platform for JSONL streaming
|
||||||
// This handles line buffering, timeouts, and abort signals automatically
|
// This handles line buffering, timeouts, and abort signals automatically
|
||||||
@@ -562,9 +696,9 @@ export class CursorProvider extends BaseProvider {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const subprocessOptions: SubprocessOptions = {
|
const subprocessOptions: SubprocessOptions = {
|
||||||
command: this.cliPath,
|
command,
|
||||||
args,
|
args,
|
||||||
cwd,
|
cwd: workingDir,
|
||||||
env: filteredEnv,
|
env: filteredEnv,
|
||||||
abortController: options.abortController,
|
abortController: options.abortController,
|
||||||
timeout: 120000, // 2 min timeout for CLI operations (may take longer than default 30s)
|
timeout: 120000, // 2 min timeout for CLI operations (may take longer than default 30s)
|
||||||
|
|||||||
@@ -72,7 +72,15 @@ export interface InstallationStatus {
|
|||||||
installed: boolean;
|
installed: boolean;
|
||||||
path?: string;
|
path?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
method?: 'cli' | 'npm' | 'brew' | 'sdk';
|
/**
|
||||||
|
* How the provider was installed/detected
|
||||||
|
* - cli: Direct CLI binary
|
||||||
|
* - wsl: CLI accessed via Windows Subsystem for Linux
|
||||||
|
* - npm: Installed via npm
|
||||||
|
* - brew: Installed via Homebrew
|
||||||
|
* - sdk: Using SDK library
|
||||||
|
*/
|
||||||
|
method?: 'cli' | 'wsl' | 'npm' | 'brew' | 'sdk';
|
||||||
hasApiKey?: boolean;
|
hasApiKey?: boolean;
|
||||||
authenticated?: boolean;
|
authenticated?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ describe('model-resolver.ts', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should treat unknown models as falling back to default', () => {
|
it('should treat unknown models as falling back to default', () => {
|
||||||
const models = ['o1', 'o1-mini', 'o3', 'gpt-5.2', 'unknown-model'];
|
// Note: Don't include valid Cursor model IDs here (e.g., 'gpt-5.2' is in CURSOR_MODEL_MAP)
|
||||||
|
const models = ['o1', 'o1-mini', 'o3', 'unknown-model', 'fake-model-123'];
|
||||||
models.forEach((model) => {
|
models.forEach((model) => {
|
||||||
const result = resolveModelString(model);
|
const result = resolveModelString(model);
|
||||||
// Should fall back to default since these aren't supported
|
// Should fall back to default since these aren't supported
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ export function SettingsView() {
|
|||||||
switch (activeView) {
|
switch (activeView) {
|
||||||
case 'providers':
|
case 'providers':
|
||||||
case 'claude': // Backwards compatibility
|
case 'claude': // Backwards compatibility
|
||||||
return <ProviderTabs defaultTab={activeView === 'claude' ? 'claude' : 'claude'} />;
|
return <ProviderTabs defaultTab={activeView === 'claude' ? 'claude' : undefined} />;
|
||||||
case 'ai-enhancement':
|
case 'ai-enhancement':
|
||||||
return <AIEnhancementSection />;
|
return <AIEnhancementSection />;
|
||||||
case 'appearance':
|
case 'appearance':
|
||||||
|
|||||||
@@ -55,3 +55,18 @@ export {
|
|||||||
type NodeFinderResult,
|
type NodeFinderResult,
|
||||||
type NodeFinderOptions,
|
type NodeFinderOptions,
|
||||||
} from './node-finder.js';
|
} from './node-finder.js';
|
||||||
|
|
||||||
|
// WSL (Windows Subsystem for Linux) utilities
|
||||||
|
export {
|
||||||
|
isWslAvailable,
|
||||||
|
clearWslCache,
|
||||||
|
getDefaultWslDistribution,
|
||||||
|
getWslDistributions,
|
||||||
|
findCliInWsl,
|
||||||
|
execInWsl,
|
||||||
|
createWslCommand,
|
||||||
|
windowsToWslPath,
|
||||||
|
wslToWindowsPath,
|
||||||
|
type WslCliResult,
|
||||||
|
type WslOptions,
|
||||||
|
} from './wsl.js';
|
||||||
|
|||||||
389
libs/platform/src/wsl.ts
Normal file
389
libs/platform/src/wsl.ts
Normal file
@@ -0,0 +1,389 @@
|
|||||||
|
/**
|
||||||
|
* WSL (Windows Subsystem for Linux) utilities
|
||||||
|
*
|
||||||
|
* Provides cross-platform support for CLI tools that are only available
|
||||||
|
* on Linux/macOS. On Windows, these tools can be accessed via WSL.
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* import { isWslAvailable, findCliInWsl, createWslCommand } from '@automaker/platform';
|
||||||
|
*
|
||||||
|
* // Check if WSL is available
|
||||||
|
* if (process.platform === 'win32' && isWslAvailable()) {
|
||||||
|
* // Find a CLI tool installed in WSL
|
||||||
|
* const cliPath = findCliInWsl('cursor-agent');
|
||||||
|
* if (cliPath) {
|
||||||
|
* // Create command/args for spawning via WSL
|
||||||
|
* const { command, args } = createWslCommand(cliPath, ['--version']);
|
||||||
|
* // command = 'wsl.exe', args = ['cursor-agent', '--version']
|
||||||
|
* }
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execSync } from 'child_process';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the full path to wsl.exe
|
||||||
|
* This is needed because spawn() may not find wsl.exe in PATH
|
||||||
|
*/
|
||||||
|
function getWslExePath(): string {
|
||||||
|
// wsl.exe is in System32 on Windows
|
||||||
|
const systemRoot = process.env.SystemRoot || process.env.SYSTEMROOT || 'C:\\Windows';
|
||||||
|
return path.join(systemRoot, 'System32', 'wsl.exe');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of finding a CLI in WSL */
|
||||||
|
export interface WslCliResult {
|
||||||
|
/** Path to the CLI inside WSL (Linux path) */
|
||||||
|
wslPath: string;
|
||||||
|
/** The WSL distribution where it was found (if detected) */
|
||||||
|
distribution?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for WSL operations */
|
||||||
|
export interface WslOptions {
|
||||||
|
/** Specific WSL distribution to use (default: use default distro) */
|
||||||
|
distribution?: string;
|
||||||
|
/** Timeout for WSL commands in milliseconds (default: 10000) */
|
||||||
|
timeout?: number;
|
||||||
|
/** Custom logger function */
|
||||||
|
logger?: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache WSL availability to avoid repeated checks
|
||||||
|
let wslAvailableCache: boolean | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if WSL is available on the current system
|
||||||
|
*
|
||||||
|
* Returns false immediately on non-Windows platforms.
|
||||||
|
* On Windows, checks if wsl.exe exists and can execute commands.
|
||||||
|
*
|
||||||
|
* Results are cached after first check.
|
||||||
|
*/
|
||||||
|
export function isWslAvailable(options: WslOptions = {}): boolean {
|
||||||
|
const { timeout = 5000, logger = () => {} } = options;
|
||||||
|
|
||||||
|
// Only relevant on Windows
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return cached result if available
|
||||||
|
if (wslAvailableCache !== null) {
|
||||||
|
return wslAvailableCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to run a simple command via WSL
|
||||||
|
execSync('wsl.exe echo ok', {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
wslAvailableCache = true;
|
||||||
|
logger('WSL is available');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
// Try wsl --status as fallback
|
||||||
|
try {
|
||||||
|
execSync('wsl.exe --status', {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
|
});
|
||||||
|
wslAvailableCache = true;
|
||||||
|
logger('WSL is available (via --status)');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
wslAvailableCache = false;
|
||||||
|
logger('WSL is not available');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the WSL availability cache
|
||||||
|
* Useful for testing or when WSL state may have changed
|
||||||
|
*/
|
||||||
|
export function clearWslCache(): void {
|
||||||
|
wslAvailableCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default WSL distribution name
|
||||||
|
*/
|
||||||
|
export function getDefaultWslDistribution(options: WslOptions = {}): string | null {
|
||||||
|
const { timeout = 5000 } = options;
|
||||||
|
|
||||||
|
if (!isWslAvailable(options)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// wsl -l -q returns distributions, first one marked with (Default)
|
||||||
|
const result = execSync('wsl.exe -l -q', {
|
||||||
|
encoding: 'utf16le', // WSL list output uses UTF-16LE on Windows
|
||||||
|
timeout,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
// First non-empty line is the default
|
||||||
|
const lines = result.split(/\r?\n/).filter((l) => l.trim());
|
||||||
|
return lines[0]?.replace(/\0/g, '').trim() || null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available WSL distributions
|
||||||
|
*/
|
||||||
|
export function getWslDistributions(options: WslOptions = {}): string[] {
|
||||||
|
const { timeout = 5000, logger = () => {} } = options;
|
||||||
|
|
||||||
|
if (!isWslAvailable(options)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = execSync('wsl.exe -l -q', {
|
||||||
|
encoding: 'utf16le', // WSL list output uses UTF-16LE on Windows
|
||||||
|
timeout,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
const distributions = result
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => l.replace(/\0/g, '').trim())
|
||||||
|
.filter((l) => l && !l.includes('docker-desktop')); // Exclude docker-desktop as it's minimal
|
||||||
|
|
||||||
|
logger(`Found WSL distributions: ${distributions.join(', ')}`);
|
||||||
|
return distributions;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a CLI tool installed in WSL
|
||||||
|
*
|
||||||
|
* Searches for the CLI using 'which' inside WSL, then checks common paths.
|
||||||
|
* If no distribution is specified, tries all available distributions (excluding docker-desktop).
|
||||||
|
*
|
||||||
|
* @param cliName - Name of the CLI to find (e.g., 'cursor-agent')
|
||||||
|
* @param options - WSL options
|
||||||
|
* @returns The Linux path to the CLI and the distribution where found, or null if not found
|
||||||
|
*/
|
||||||
|
export function findCliInWsl(cliName: string, options: WslOptions = {}): WslCliResult | null {
|
||||||
|
const { distribution, timeout = 10000, logger = () => {} } = options;
|
||||||
|
|
||||||
|
if (!isWslAvailable(options)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper to search in a specific distribution
|
||||||
|
const searchInDistribution = (distro: string | undefined): WslCliResult | null => {
|
||||||
|
const wslPrefix = distro ? `wsl.exe -d ${distro}` : 'wsl.exe';
|
||||||
|
const distroLabel = distro || 'default';
|
||||||
|
|
||||||
|
// Try 'which' first (works if PATH is set up correctly)
|
||||||
|
try {
|
||||||
|
const result = execSync(`${wslPrefix} which ${cliName}`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
if (result && !result.includes('not found') && result.startsWith('/')) {
|
||||||
|
logger(`Found ${cliName} in WSL (${distroLabel}) via 'which': ${result}`);
|
||||||
|
return { wslPath: result, distribution: distro };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not found via which, continue to path checks
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check common installation paths using sh -c for better compatibility
|
||||||
|
// Use $HOME instead of ~ for reliable expansion
|
||||||
|
const commonPaths = ['$HOME/.local/bin', '/usr/local/bin', '/usr/bin'];
|
||||||
|
|
||||||
|
for (const basePath of commonPaths) {
|
||||||
|
try {
|
||||||
|
// Use sh -c to properly expand $HOME and test if executable
|
||||||
|
const checkCmd = `${wslPrefix} sh -c "test -x ${basePath}/${cliName} && echo ${basePath}/${cliName}"`;
|
||||||
|
const result = execSync(checkCmd, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
if (result && result.startsWith('/')) {
|
||||||
|
logger(`Found ${cliName} in WSL (${distroLabel}) at: ${result}`);
|
||||||
|
return { wslPath: result, distribution: distro };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Path doesn't exist or not executable, continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// If a specific distribution is requested, only search there
|
||||||
|
if (distribution) {
|
||||||
|
return searchInDistribution(distribution);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try available distributions (excluding docker-desktop and similar minimal distros)
|
||||||
|
const distributions = getWslDistributions(options);
|
||||||
|
|
||||||
|
// Prioritize common user distributions
|
||||||
|
const priorityDistros = ['Ubuntu', 'Debian', 'openSUSE', 'Fedora', 'Arch'];
|
||||||
|
const sortedDistros = distributions.sort((a, b) => {
|
||||||
|
const aIndex = priorityDistros.findIndex((p) => a.toLowerCase().includes(p.toLowerCase()));
|
||||||
|
const bIndex = priorityDistros.findIndex((p) => b.toLowerCase().includes(p.toLowerCase()));
|
||||||
|
if (aIndex === -1 && bIndex === -1) return 0;
|
||||||
|
if (aIndex === -1) return 1;
|
||||||
|
if (bIndex === -1) return -1;
|
||||||
|
return aIndex - bIndex;
|
||||||
|
});
|
||||||
|
|
||||||
|
logger(`Searching for ${cliName} in WSL distributions: ${sortedDistros.join(', ')}`);
|
||||||
|
|
||||||
|
for (const distro of sortedDistros) {
|
||||||
|
const result = searchInDistribution(distro);
|
||||||
|
if (result) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: try default distribution as last resort
|
||||||
|
const defaultResult = searchInDistribution(undefined);
|
||||||
|
if (defaultResult) {
|
||||||
|
return defaultResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger(`${cliName} not found in any WSL distribution`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a command in WSL and return the output
|
||||||
|
*
|
||||||
|
* @param command - Command to execute (can include arguments)
|
||||||
|
* @param options - WSL options
|
||||||
|
* @returns Command output, or null if failed
|
||||||
|
*/
|
||||||
|
export function execInWsl(command: string, options: WslOptions = {}): string | null {
|
||||||
|
const { distribution, timeout = 30000 } = options;
|
||||||
|
|
||||||
|
if (!isWslAvailable(options)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const wslPrefix = distribution ? `wsl.exe -d ${distribution}` : 'wsl.exe';
|
||||||
|
|
||||||
|
try {
|
||||||
|
return execSync(`${wslPrefix} ${command}`, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout,
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true,
|
||||||
|
}).trim();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create command and arguments for spawning a process via WSL
|
||||||
|
*
|
||||||
|
* This is useful for constructing spawn() calls that work through WSL.
|
||||||
|
* Uses the full path to wsl.exe to ensure spawn() can find it.
|
||||||
|
*
|
||||||
|
* @param wslCliPath - The Linux path to the CLI inside WSL
|
||||||
|
* @param args - Arguments to pass to the CLI
|
||||||
|
* @param options - WSL options
|
||||||
|
* @returns Object with command (full path to wsl.exe) and modified args
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```typescript
|
||||||
|
* const { command, args } = createWslCommand('/home/user/.local/bin/cursor-agent', ['-p', 'hello']);
|
||||||
|
* // command = 'C:\\Windows\\System32\\wsl.exe'
|
||||||
|
* // args = ['/home/user/.local/bin/cursor-agent', '-p', 'hello']
|
||||||
|
*
|
||||||
|
* spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function createWslCommand(
|
||||||
|
wslCliPath: string,
|
||||||
|
args: string[],
|
||||||
|
options: WslOptions = {}
|
||||||
|
): { command: string; args: string[] } {
|
||||||
|
const { distribution } = options;
|
||||||
|
// Use full path to wsl.exe to ensure spawn() can find it
|
||||||
|
const wslExe = getWslExePath();
|
||||||
|
|
||||||
|
if (distribution) {
|
||||||
|
return {
|
||||||
|
command: wslExe,
|
||||||
|
args: ['-d', distribution, wslCliPath, ...args],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
command: wslExe,
|
||||||
|
args: [wslCliPath, ...args],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a Windows path to a WSL path
|
||||||
|
*
|
||||||
|
* @param windowsPath - Windows path (e.g., 'C:\\Users\\foo\\project')
|
||||||
|
* @returns WSL path (e.g., '/mnt/c/Users/foo/project')
|
||||||
|
*/
|
||||||
|
export function windowsToWslPath(windowsPath: string): string {
|
||||||
|
// Handle UNC paths
|
||||||
|
if (windowsPath.startsWith('\\\\')) {
|
||||||
|
// UNC paths are not directly supported, return as-is
|
||||||
|
return windowsPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract drive letter and convert
|
||||||
|
const match = windowsPath.match(/^([A-Za-z]):\\(.*)$/);
|
||||||
|
if (match) {
|
||||||
|
const [, drive, rest] = match;
|
||||||
|
const wslPath = `/mnt/${drive.toLowerCase()}/${rest.replace(/\\/g, '/')}`;
|
||||||
|
return wslPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already a Unix-style path or relative path
|
||||||
|
return windowsPath.replace(/\\/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a WSL path to a Windows path
|
||||||
|
*
|
||||||
|
* @param wslPath - WSL path (e.g., '/mnt/c/Users/foo/project')
|
||||||
|
* @returns Windows path (e.g., 'C:\\Users\\foo\\project'), or original if not a /mnt/ path
|
||||||
|
*/
|
||||||
|
export function wslToWindowsPath(wslPath: string): string {
|
||||||
|
const match = wslPath.match(/^\/mnt\/([a-z])\/(.*)$/);
|
||||||
|
if (match) {
|
||||||
|
const [, drive, rest] = match;
|
||||||
|
return `${drive.toUpperCase()}:\\${rest.replace(/\//g, '\\')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a /mnt/ path, return as-is
|
||||||
|
return wslPath;
|
||||||
|
}
|
||||||
@@ -81,7 +81,15 @@ export interface InstallationStatus {
|
|||||||
installed: boolean;
|
installed: boolean;
|
||||||
path?: string;
|
path?: string;
|
||||||
version?: string;
|
version?: string;
|
||||||
method?: 'cli' | 'npm' | 'brew' | 'sdk';
|
/**
|
||||||
|
* How the provider was installed/detected
|
||||||
|
* - cli: Direct CLI binary
|
||||||
|
* - wsl: CLI accessed via Windows Subsystem for Linux
|
||||||
|
* - npm: Installed via npm
|
||||||
|
* - brew: Installed via Homebrew
|
||||||
|
* - sdk: Using SDK library
|
||||||
|
*/
|
||||||
|
method?: 'cli' | 'wsl' | 'npm' | 'brew' | 'sdk';
|
||||||
hasApiKey?: boolean;
|
hasApiKey?: boolean;
|
||||||
authenticated?: boolean;
|
authenticated?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user