Files
ONE-OS/axhub-make/vite-plugins/aiCliPlugin.ts
王冕 a27e3b8e43 feat: sync full workspace including web modules, docs, and configurations to Gitea
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>
2026-06-09 18:12:25 +08:00

399 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
}
};
}