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>
This commit is contained in:
398
axhub-make/vite-plugins/aiCliPlugin.ts
Normal file
398
axhub-make/vite-plugins/aiCliPlugin.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
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();
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user