/**
* Gemini CLI 백엔드
*
* Google One 구독자를 위한 높은 쿼터 지원
* - Free: 60 RPM, 1,000 RPD
* - Google One: 더 높은 제한
*/
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
// ============================================================
// Types
// ============================================================
export interface CliResponse {
session_id: string;
response: string;
stats?: {
models?: Record<
string,
{
tokens?: {
input?: number;
prompt?: number;
candidates?: number;
total?: number;
};
}
>;
};
}
export interface CliResult {
success: boolean;
text?: string;
sessionId?: string;
model?: string;
tokenUsage?: {
promptTokens: number;
completionTokens: number;
totalTokens: number;
};
error?: string;
}
export interface SessionInfo {
index: number;
title: string;
timeAgo: string;
sessionId: string;
}
// ============================================================
// CLI Availability Check
// ============================================================
let cliPath: string | null = null;
export function findGeminiCli(): string | null {
if (cliPath !== null) return cliPath;
const possiblePaths = [
'/opt/homebrew/bin/gemini',
'/usr/local/bin/gemini',
`${os.homedir()}/.local/bin/gemini`,
];
for (const p of possiblePaths) {
if (fs.existsSync(p)) {
cliPath = p;
return cliPath;
}
}
cliPath = '';
return null;
}
export function isCliAvailable(): boolean {
return findGeminiCli() !== null;
}
// ============================================================
// CLI Execution
// ============================================================
export async function runGeminiCli(
prompt: string,
options: { model?: string; timeout?: number; resume?: string | number } = {}
): Promise<CliResult> {
const geminiPath = findGeminiCli();
if (!geminiPath) {
return {
success: false,
error: 'Gemini CLI가 설치되어 있지 않습니다. `npm install -g @anthropic-ai/gemini-cli`로 설치하세요.',
};
}
const { model, timeout = 120000, resume } = options;
return new Promise((resolve) => {
const args = ['-o', 'json'];
if (model) {
args.push('-m', model);
}
if (resume !== undefined) {
args.push('-r', String(resume));
}
const child = spawn(geminiPath, args, {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
let stdout = '';
let stderr = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
const timer = setTimeout(() => {
child.kill('SIGTERM');
resolve({
success: false,
error: `CLI 실행 시간 초과 (${timeout / 1000}초)`,
});
}, timeout);
child.on('close', (code) => {
clearTimeout(timer);
if (code !== 0) {
resolve({
success: false,
error: stderr || `CLI가 코드 ${code}로 종료됨`,
});
return;
}
try {
// Find JSON in output (skip initial log lines)
const jsonStart = stdout.indexOf('{');
if (jsonStart === -1) {
resolve({
success: false,
error: 'CLI 응답에서 JSON을 찾을 수 없습니다.',
});
return;
}
const jsonStr = stdout.slice(jsonStart);
const response: CliResponse = JSON.parse(jsonStr);
// Extract token usage from stats
let tokenUsage: CliResult['tokenUsage'];
if (response.stats?.models) {
const models = Object.values(response.stats.models);
if (models.length > 0 && models[0].tokens) {
const tokens = models[0].tokens;
tokenUsage = {
promptTokens: tokens.prompt || tokens.input || 0,
completionTokens: tokens.candidates || 0,
totalTokens: tokens.total || 0,
};
}
}
// Get model name
const modelName = response.stats?.models
? Object.keys(response.stats.models)[0]
: undefined;
resolve({
success: true,
text: response.response,
sessionId: response.session_id,
model: modelName,
tokenUsage,
});
} catch (e: any) {
resolve({
success: false,
error: `JSON 파싱 오류: ${e.message}`,
});
}
});
child.on('error', (err) => {
clearTimeout(timer);
resolve({
success: false,
error: `CLI 실행 오류: ${err.message}`,
});
});
// Send prompt to stdin
child.stdin.write(prompt);
child.stdin.end();
});
}
// ============================================================
// Session Management
// ============================================================
export async function listSessions(): Promise<SessionInfo[]> {
const geminiPath = findGeminiCli();
if (!geminiPath) {
return [];
}
return new Promise((resolve) => {
const child = spawn(geminiPath, ['--list-sessions'], {
stdio: ['pipe', 'pipe', 'pipe'],
env: { ...process.env },
});
let stdout = '';
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.on('close', () => {
const sessions: SessionInfo[] = [];
// Parse format: " 1. Title (time ago) [session-id]"
const regex = /^\s*(\d+)\.\s+(.+?)\s+\((.+?)\)\s+\[([a-f0-9-]+)\]/gm;
let match;
while ((match = regex.exec(stdout)) !== null) {
sessions.push({
index: parseInt(match[1], 10),
title: match[2].trim(),
timeAgo: match[3],
sessionId: match[4],
});
}
resolve(sessions);
});
child.on('error', () => {
resolve([]);
});
});
}
// ============================================================
// File Analysis via CLI
// ============================================================
export async function analyzeFileViaCli(
filePath: string,
prompt: string,
options: { model?: string; timeout?: number; resume?: string | number } = {}
): Promise<CliResult> {
// Expand ~ in path
const absolutePath = filePath.startsWith('~')
? filePath.replace('~', os.homedir())
: path.resolve(filePath);
if (!fs.existsSync(absolutePath)) {
return {
success: false,
error: `파일을 찾을 수 없습니다: ${absolutePath}`,
};
}
// For CLI, we include file path in the prompt
// CLI will handle the file reading
const fullPrompt = `파일 분석: ${absolutePath}\n\n${prompt}`;
return runGeminiCli(fullPrompt, options);
}