Optimized the root .gitignore to exclude virtual environments, node modules, and temp folders to ensure clean and lightweight version tracking. Co-authored-by: Cursor <cursoragent@cursor.com>
399 lines
14 KiB
TypeScript
399 lines
14 KiB
TypeScript
import type { Plugin } from 'vite';
|
||
import { spawn } from 'node:child_process';
|
||
import { commandExists, decodeOutput, getSpawnCommandSpec } from '../scripts/utils/command-runtime.mjs';
|
||
|
||
type AIType = 'claude' | 'gemini' | 'opencode' | 'cursor' | 'codex';
|
||
|
||
interface RunAIOptions {
|
||
cli: AIType;
|
||
prompt: string;
|
||
silent?: boolean;
|
||
interactive?: boolean;
|
||
}
|
||
|
||
/**
|
||
* 检测 CLI 是否存在
|
||
*/
|
||
function hasCommand(cmd: string): boolean {
|
||
return commandExists(cmd);
|
||
}
|
||
|
||
type CliSpawnConfig = {
|
||
command: string;
|
||
args: string[];
|
||
useStdin: boolean;
|
||
input?: string;
|
||
};
|
||
|
||
/**
|
||
* CLI 适配器(严格对齐官方用法)
|
||
*/
|
||
const CLI_ADAPTERS: Record<AIType, (opts: RunAIOptions) => CliSpawnConfig> = {
|
||
claude: ({ prompt, interactive }) => {
|
||
if (interactive) {
|
||
// 官方:claude [prompt] → 启动交互式会话,并把 prompt 作为首条消息
|
||
return { command: 'claude', args: [prompt], useStdin: false };
|
||
}
|
||
// 官方 headless 模式
|
||
return { command: 'claude', args: ['-p', prompt], useStdin: false };
|
||
},
|
||
gemini: ({ prompt, interactive }) => {
|
||
if (interactive) {
|
||
// 官方:gemini -i "<prompt>" → 执行 prompt 并进入交互模式
|
||
return { command: 'gemini', args: ['-i', prompt], useStdin: false };
|
||
}
|
||
// Gemini CLI 通过 stdin 接收输入
|
||
return { command: 'gemini', args: [], useStdin: true, input: prompt };
|
||
},
|
||
opencode: ({ prompt, interactive }) => {
|
||
if (interactive) {
|
||
// 启动 OpenCode TUI,并附带首条 prompt(如 CLI 版本不支持该参数,会在运行时报错)
|
||
return { command: 'opencode', args: ['--prompt', prompt], useStdin: false };
|
||
}
|
||
// 非交互模式:opencode run "prompt"
|
||
return { command: 'opencode', args: ['run', prompt], useStdin: false };
|
||
},
|
||
cursor: ({ prompt, interactive }) => {
|
||
if (interactive) {
|
||
// 启动 Cursor Agent 交互式会话
|
||
return { command: 'agent', args: [prompt], useStdin: false };
|
||
}
|
||
// 非交互模式(打印模式):agent -p "prompt" --output-format text
|
||
return { command: 'agent', args: ['-p', prompt, '--output-format', 'text'], useStdin: false };
|
||
},
|
||
codex: ({ prompt, interactive }) => {
|
||
if (interactive) {
|
||
// 启动 Codex 交互式 TUI
|
||
return { command: 'codex', args: [prompt], useStdin: false };
|
||
}
|
||
// 非交互模式:codex exec "prompt" --full-auto
|
||
// --full-auto: 低摩擦自动化模式(workspace-write sandbox + on-request approvals)
|
||
return { command: 'codex', args: ['exec', prompt, '--full-auto'], useStdin: false };
|
||
},
|
||
};
|
||
|
||
function spawnAIProcess(options: RunAIOptions) {
|
||
const {
|
||
cli,
|
||
prompt,
|
||
silent = false,
|
||
interactive = false,
|
||
} = options;
|
||
|
||
// Cursor 使用 'agent' 命令,需要特殊检测
|
||
const commandToCheck = cli === 'cursor' ? 'agent' : cli;
|
||
if (!hasCommand(commandToCheck)) {
|
||
throw new Error(`CLI not found: ${cli} (command: ${commandToCheck})`);
|
||
}
|
||
|
||
// 交互式 → 强制非静默(否则用户看不到 TUI)
|
||
const finalSilent = interactive ? false : silent;
|
||
|
||
const adapter = CLI_ADAPTERS[cli];
|
||
if (!adapter) {
|
||
throw new Error(`Unsupported CLI: ${cli}`);
|
||
}
|
||
|
||
const config = adapter({ ...options, interactive });
|
||
const { command, args, useStdin, input } = config;
|
||
|
||
console.log(
|
||
`[AI CLI] Spawning command: ${command} ${args.join(' ')}${useStdin ? ' (with stdin)' : ''}`,
|
||
);
|
||
|
||
const spawnSpec = getSpawnCommandSpec(command, args);
|
||
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
||
stdio: finalSilent
|
||
? ['pipe', 'pipe', 'pipe']
|
||
: (useStdin ? ['pipe', 'inherit', 'inherit'] : 'inherit'),
|
||
env: process.env,
|
||
shell: false,
|
||
windowsHide: spawnSpec.windowsHide,
|
||
});
|
||
|
||
// 如果需要通过 stdin 传递输入
|
||
if (useStdin && input && child.stdin) {
|
||
console.log(`[AI CLI] Writing to stdin: ${input.substring(0, 50)}...`);
|
||
child.stdin.write(input);
|
||
child.stdin.end();
|
||
}
|
||
|
||
return { child, finalSilent };
|
||
}
|
||
|
||
/**
|
||
* 统一执行入口
|
||
*/
|
||
function runAICommand(options: RunAIOptions): Promise<string> {
|
||
return new Promise<string>((resolve, reject) => {
|
||
const { child, finalSilent } = spawnAIProcess(options);
|
||
|
||
let output = '';
|
||
let errorOutput = '';
|
||
|
||
// 仅在非交互模式下设置超时(交互式会话不应被强制终止)
|
||
const timeoutMs = options.interactive ? 0 : 60000;
|
||
const timeout = timeoutMs
|
||
? setTimeout(() => {
|
||
console.error(`[AI CLI] Command timeout after ${Math.round(timeoutMs / 1000)}s`);
|
||
child.kill('SIGTERM');
|
||
reject(new Error(`Command execution timeout (${Math.round(timeoutMs / 1000)}s)`));
|
||
}, timeoutMs)
|
||
: null;
|
||
|
||
// 捕获输出
|
||
if (finalSilent && child.stdout && child.stderr) {
|
||
child.stdout.on('data', (data) => {
|
||
const chunk = decodeOutput(data);
|
||
output += chunk;
|
||
console.log(`[AI CLI] stdout chunk: ${chunk.substring(0, 100)}${chunk.length > 100 ? '...' : ''}`);
|
||
});
|
||
|
||
child.stderr.on('data', (data) => {
|
||
const chunk = decodeOutput(data);
|
||
errorOutput += chunk;
|
||
console.error(`[AI CLI] stderr chunk: ${chunk.substring(0, 100)}${chunk.length > 100 ? '...' : ''}`);
|
||
});
|
||
}
|
||
|
||
// 错误处理
|
||
child.on('error', (error) => {
|
||
if (timeout) clearTimeout(timeout);
|
||
console.error(`[AI CLI] Process error:`, error);
|
||
reject(error);
|
||
});
|
||
|
||
// 进程关闭 - 这是主要的完成事件
|
||
child.on('close', (code, signal) => {
|
||
if (timeout) clearTimeout(timeout);
|
||
console.log(`[AI CLI] Process closed with code ${code}, signal ${signal}`);
|
||
|
||
if (code === 0) {
|
||
const result = output.trim() || errorOutput.trim() || 'Command executed successfully';
|
||
console.log(`[AI CLI] Success, output length: ${result.length}`);
|
||
resolve(result);
|
||
} else {
|
||
const errorMsg = `CLI exited with code ${code}${signal ? ` (signal: ${signal})` : ''}\nOutput: ${output}\nError: ${errorOutput}`;
|
||
console.error(`[AI CLI] Failed:`, errorMsg);
|
||
reject(new Error(errorMsg));
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* AI CLI Plugin
|
||
* Provides API endpoints for executing local AI CLI commands (Claude, Gemini)
|
||
*/
|
||
export function aiCliPlugin(): Plugin {
|
||
// 防抖机制:记录正在执行的任务
|
||
const runningTasks = new Map<string, { promise: Promise<string>; timestamp: number }>();
|
||
const DEBOUNCE_TIME = 2000; // 2秒内的相同请求会被合并
|
||
let interactiveSession:
|
||
| { pid: number; cli: AIType; startedAt: number }
|
||
| null = null;
|
||
|
||
return {
|
||
name: 'ai-cli-api',
|
||
|
||
configureServer(server) {
|
||
// Helper functions
|
||
const sendJSON = (res: any, statusCode: number, data: any) => {
|
||
res.statusCode = statusCode;
|
||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||
res.end(JSON.stringify(data));
|
||
};
|
||
|
||
const sendError = (res: any, statusCode: number, message: string) => {
|
||
sendJSON(res, statusCode, {
|
||
error: message,
|
||
timestamp: new Date().toISOString()
|
||
});
|
||
};
|
||
|
||
// 生成任务唯一标识
|
||
const getTaskKey = (cli: string, prompt: string): string => {
|
||
return `${cli}:${prompt}`;
|
||
};
|
||
|
||
// 清理过期任务
|
||
const cleanupExpiredTasks = () => {
|
||
const now = Date.now();
|
||
for (const [key, task] of runningTasks.entries()) {
|
||
if (now - task.timestamp > DEBOUNCE_TIME) {
|
||
runningTasks.delete(key);
|
||
}
|
||
}
|
||
};
|
||
|
||
// Middleware to handle API routes
|
||
server.middlewares.use(async (req, res, next) => {
|
||
const url = req.url || '';
|
||
|
||
// GET /api/ai/execute - Execute AI CLI command
|
||
if (url.startsWith('/api/ai/execute') && req.method === 'GET') {
|
||
try {
|
||
const urlObj = new URL(url, `http://${req.headers.host}`);
|
||
const cli = urlObj.searchParams.get('cli') as AIType;
|
||
const prompt = urlObj.searchParams.get('prompt');
|
||
const silent = urlObj.searchParams.get('silent') !== 'false'; // 默认 true
|
||
const interactive = urlObj.searchParams.get('interactive') === 'true'; // 默认 false
|
||
|
||
// Validate required fields
|
||
if (!cli || !['claude', 'gemini', 'opencode', 'cursor', 'codex'].includes(cli)) {
|
||
return sendError(res, 400, 'Invalid or missing "cli" parameter. Must be "claude", "gemini", "opencode", "cursor", or "codex"');
|
||
}
|
||
|
||
if (!prompt || typeof prompt !== 'string') {
|
||
return sendError(res, 400, 'Invalid or missing "prompt" parameter');
|
||
}
|
||
|
||
// Check if CLI is available
|
||
const commandToCheck = cli === 'cursor' ? 'agent' : cli;
|
||
if (!hasCommand(commandToCheck)) {
|
||
return sendError(res, 404, `CLI not found: ${cli}. Please install it first.`);
|
||
}
|
||
|
||
// 交互式会话:直接在 dev server 的终端里启动 TUI,不等待结束(避免 HTTP 长连接挂起)
|
||
if (interactive) {
|
||
if (interactiveSession) {
|
||
return sendError(
|
||
res,
|
||
409,
|
||
`Interactive session already running (cli: ${interactiveSession.cli}, pid: ${interactiveSession.pid}). Please exit it before starting a new one.`,
|
||
);
|
||
}
|
||
|
||
console.log(`[AI CLI] Launching interactive ${cli} session...`);
|
||
|
||
try {
|
||
const { child } = spawnAIProcess({
|
||
cli,
|
||
prompt,
|
||
silent: false,
|
||
interactive: true,
|
||
});
|
||
|
||
interactiveSession = {
|
||
pid: child.pid ?? -1,
|
||
cli,
|
||
startedAt: Date.now(),
|
||
};
|
||
|
||
child.on('close', () => {
|
||
interactiveSession = null;
|
||
});
|
||
|
||
sendJSON(res, 202, {
|
||
success: true,
|
||
cli,
|
||
interactive: true,
|
||
pid: child.pid ?? null,
|
||
message: 'Interactive session started in the dev server terminal.',
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
} catch (error: any) {
|
||
console.error('[AI CLI] Interactive launch error:', error);
|
||
interactiveSession = null;
|
||
sendError(res, 500, error.message || 'Failed to launch interactive AI CLI session');
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
// 防抖检查
|
||
cleanupExpiredTasks();
|
||
const taskKey = getTaskKey(cli, prompt);
|
||
const existingTask = runningTasks.get(taskKey);
|
||
|
||
if (existingTask) {
|
||
console.log(`[AI CLI] Reusing existing task for ${cli}:`, prompt.substring(0, 50) + '...');
|
||
try {
|
||
const result = await existingTask.promise;
|
||
sendJSON(res, 200, {
|
||
success: true,
|
||
cli,
|
||
output: result || 'Command executed successfully',
|
||
cached: true,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
} catch (error: any) {
|
||
console.error('[AI CLI] Cached task error:', error);
|
||
sendError(res, 500, error.message || 'Failed to execute AI CLI command');
|
||
}
|
||
return;
|
||
}
|
||
|
||
console.log(`[AI CLI] Executing ${cli} command:`, {
|
||
prompt: prompt.substring(0, 50) + '...',
|
||
silent,
|
||
interactive
|
||
});
|
||
|
||
// 创建新任务
|
||
const taskPromise = runAICommand({
|
||
cli,
|
||
prompt,
|
||
silent,
|
||
interactive,
|
||
});
|
||
|
||
// 记录任务
|
||
runningTasks.set(taskKey, {
|
||
promise: taskPromise,
|
||
timestamp: Date.now(),
|
||
});
|
||
|
||
// 执行任务
|
||
try {
|
||
const result = await taskPromise;
|
||
sendJSON(res, 200, {
|
||
success: true,
|
||
cli,
|
||
output: result || 'Command executed successfully',
|
||
cached: false,
|
||
timestamp: new Date().toISOString(),
|
||
});
|
||
} catch (error: any) {
|
||
console.error('[AI CLI] Execution error:', error);
|
||
sendError(res, 500, error.message || 'Failed to execute AI CLI command');
|
||
} finally {
|
||
// 延迟删除任务,允许短时间内的重复请求复用结果
|
||
setTimeout(() => {
|
||
runningTasks.delete(taskKey);
|
||
}, DEBOUNCE_TIME);
|
||
}
|
||
} catch (error: any) {
|
||
console.error('[AI CLI] Request handling error:', error);
|
||
sendError(res, 500, error.message || 'Internal server error');
|
||
}
|
||
return;
|
||
}
|
||
|
||
// GET /api/ai/status - Check CLI availability
|
||
if (url.startsWith('/api/ai/status') && req.method === 'GET') {
|
||
try {
|
||
const status = {
|
||
claude: hasCommand('claude'),
|
||
gemini: hasCommand('gemini'),
|
||
opencode: hasCommand('opencode'),
|
||
cursor: hasCommand('agent'), // Cursor CLI 使用 'agent' 命令
|
||
codex: hasCommand('codex'),
|
||
runningTasks: runningTasks.size,
|
||
timestamp: new Date().toISOString(),
|
||
};
|
||
|
||
sendJSON(res, 200, status);
|
||
} catch (error: any) {
|
||
console.error('[AI CLI] Status check error:', error);
|
||
sendError(res, 500, error.message || 'Failed to check CLI status');
|
||
}
|
||
return;
|
||
}
|
||
|
||
next();
|
||
});
|
||
}
|
||
};
|
||
}
|