aios-core
Version:
Synkra AIOS: AI-Orchestrated System for Full Stack Development - Core Framework
366 lines (322 loc) • 10.5 kB
JavaScript
/**
* @fileoverview Gemini CLI Provider
*
* AI Provider implementation for Google's Gemini CLI.
* Wraps the `gemini` CLI command for prompt execution.
* Supports JSON output mode for structured responses.
*
* @see Epic GEMINI-INT - Story 2: AI Provider Factory Pattern
* @see Epic GEMINI-INT - Story 4: JSON Output Integration
*/
const { spawn, execSync } = require('child_process');
const { AIProvider } = require('./ai-provider');
/**
* Gemini CLI provider implementation
*
* @class GeminiProvider
* @extends AIProvider
*/
class GeminiProvider extends AIProvider {
/**
* Create a Gemini provider
* @param {Object} [config={}] - Provider configuration
* @param {string} [config.model='gemini-2.0-flash'] - Model to use (gemini-2.0-flash or gemini-2.0-pro)
* @param {number} [config.timeout=300000] - Execution timeout
* @param {boolean} [config.previewFeatures=true] - Enable preview features for Gemini 3
* @param {boolean} [config.jsonOutput=false] - Use JSON output format by default
*/
constructor(config = {}) {
super({
name: 'gemini',
command: 'gemini',
timeout: config.timeout || 300000,
maxRetries: config.maxRetries || 3,
options: {
model: config.model || 'gemini-2.0-flash',
previewFeatures: config.previewFeatures !== false, // Default true
jsonOutput: config.jsonOutput || false,
...config,
},
});
}
/**
* Check if Gemini CLI is available
* @returns {Promise<boolean>} True if available
*/
async checkAvailability() {
try {
const version = execSync('gemini --version', {
encoding: 'utf8',
timeout: 5000,
windowsHide: true,
}).trim();
this.isAvailable = true;
this.version = version;
// Check authentication
try {
const authStatus = execSync('gemini auth status 2>&1', {
encoding: 'utf8',
timeout: 5000,
windowsHide: true,
});
this.isAuthenticated = !authStatus.toLowerCase().includes('not authenticated');
} catch {
this.isAuthenticated = false;
}
return true;
} catch (error) {
this.isAvailable = false;
this.lastError = error;
return false;
}
}
/**
* Execute a prompt using Gemini CLI
* @param {string} prompt - The prompt to send
* @param {Object} [options={}] - Execution options
* @param {boolean} [options.jsonOutput] - Use JSON output format
* @param {string} [options.model] - Override model
* @returns {Promise<AIResponse>} The AI response
*/
async execute(prompt, options = {}) {
const startTime = Date.now();
const workingDir = options.workingDir || process.cwd();
const timeout = options.timeout || this.timeout;
const useJsonOutput = options.jsonOutput ?? this.options.jsonOutput;
// Build command arguments (without prompt - will use stdin)
const args = [];
// JSON output mode for programmatic integration
if (useJsonOutput) {
args.push('--output-format', 'json');
}
// Model selection (if specified)
if (options.model || this.options.model) {
args.push('--model', options.model || this.options.model);
}
return new Promise((resolve, reject) => {
let stdout = '';
let stderr = '';
// Spawn gemini directly without shell interpolation (safer)
const child = spawn(this.command, args, {
cwd: workingDir,
env: { ...process.env, ...options.env },
windowsHide: true,
stdio: ['pipe', 'pipe', 'pipe'],
});
// Write prompt via stdin to avoid shell injection
child.stdin.write(prompt);
child.stdin.end();
const timeoutId = setTimeout(() => {
child.kill('SIGTERM');
reject(new Error(`Gemini execution timed out after ${timeout}ms`));
}, timeout);
child.stdout.on('data', (data) => {
stdout += data.toString();
});
child.stderr.on('data', (data) => {
stderr += data.toString();
});
child.on('close', (code) => {
clearTimeout(timeoutId);
const duration = Date.now() - startTime;
if (code === 0) {
// Parse JSON output if requested
if (useJsonOutput) {
try {
const parsed = JSON.parse(stdout);
resolve({
success: true,
output: parsed.response || parsed.text || stdout,
data: parsed,
metadata: {
duration,
provider: 'gemini',
model: options.model || this.options.model,
stats: parsed.stats,
},
});
} catch (parseError) {
// JSON parsing failed, return raw output
resolve({
success: true,
output: stdout.trim(),
metadata: {
duration,
provider: 'gemini',
model: options.model || this.options.model,
jsonParseError: parseError.message,
},
});
}
} else {
resolve({
success: true,
output: stdout.trim(),
metadata: {
duration,
provider: 'gemini',
model: options.model || this.options.model,
},
});
}
} else {
reject(new Error(`Gemini exited with code ${code}: ${stderr || stdout}`));
}
});
child.on('error', (error) => {
clearTimeout(timeoutId);
reject(new Error(`Gemini spawn error: ${error.message}`));
});
});
}
/**
* Execute with JSON output mode
* @param {string} prompt - The prompt to send
* @param {Object} [options={}] - Execution options
* @returns {Promise<AIResponse>} Response with parsed data
*/
async executeJson(prompt, options = {}) {
return this.execute(prompt, { ...options, jsonOutput: true });
}
/**
* Execute with a specific model (convenience method)
* @param {string} prompt - The prompt to send
* @param {'flash'|'pro'} modelType - Model type shorthand
* @param {Object} [options={}] - Execution options
* @returns {Promise<AIResponse>} The AI response
*/
async executeWithModel(prompt, modelType, options = {}) {
const modelMap = {
flash: 'gemini-2.0-flash',
pro: 'gemini-2.0-pro',
};
return this.execute(prompt, {
...options,
model: modelMap[modelType] || modelType,
});
}
/**
* Execute and parse structured JSON response (Story 4)
* Handles various JSON response formats from Gemini
* @param {string} prompt - The prompt to send
* @param {Object} [options={}] - Execution options
* @returns {Promise<Object>} Parsed JSON data
*/
async executeStructured(prompt, options = {}) {
const response = await this.executeJson(prompt, options);
if (!response.success) {
throw new Error(response.error || 'Execution failed');
}
// If already parsed by executeJson
if (response.data) {
return response.data;
}
// Try to extract JSON from output
return this.parseJsonFromOutput(response.output);
}
/**
* Parse JSON from potentially mixed output (Story 4)
* @param {string} output - Raw output that may contain JSON
* @returns {Object} Parsed JSON object
*/
parseJsonFromOutput(output) {
if (!output) {
throw new Error('Empty output');
}
// Try direct parse first
try {
return JSON.parse(output);
} catch {
// Continue with extraction
}
// Try to find JSON object
const objectMatch = output.match(/\{[\s\S]*\}/);
if (objectMatch) {
try {
return JSON.parse(objectMatch[0]);
} catch {
// Continue
}
}
// Try to find JSON array
const arrayMatch = output.match(/\[[\s\S]*\]/);
if (arrayMatch) {
try {
return JSON.parse(arrayMatch[0]);
} catch {
// Continue
}
}
// Try to find JSON in code blocks
const codeBlockMatch = output.match(/```(?:json)?\s*([\s\S]*?)```/);
if (codeBlockMatch) {
try {
return JSON.parse(codeBlockMatch[1].trim());
} catch {
// Continue
}
}
throw new Error('No valid JSON found in output');
}
/**
* Execute with schema validation (Story 4)
* @param {string} prompt - The prompt to send
* @param {Object} schema - Expected schema (simple field validation)
* @param {Object} [options={}] - Execution options
* @returns {Promise<Object>} Validated JSON data
*/
async executeWithSchema(prompt, schema, options = {}) {
const data = await this.executeStructured(prompt, options);
// Simple schema validation
const errors = this.validateSchema(data, schema);
if (errors.length > 0) {
throw new Error(`Schema validation failed: ${errors.join(', ')}`);
}
return data;
}
/**
* Simple schema validation (Story 4)
* @param {Object} data - Data to validate
* @param {Object} schema - Schema with required fields
* @returns {Array<string>} Validation errors
*/
validateSchema(data, schema) {
const errors = [];
if (!schema || typeof schema !== 'object') {
return errors;
}
// Check required fields
if (schema.required && Array.isArray(schema.required)) {
for (const field of schema.required) {
if (!(field in data)) {
errors.push(`Missing required field: ${field}`);
}
}
}
// Check field types
if (schema.properties && typeof schema.properties === 'object') {
for (const [field, spec] of Object.entries(schema.properties)) {
if (field in data && spec.type) {
const actualType = Array.isArray(data[field]) ? 'array' : typeof data[field];
if (actualType !== spec.type) {
errors.push(`Field ${field} should be ${spec.type}, got ${actualType}`);
}
}
}
}
return errors;
}
/**
* Get provider info including Gemini-specific details
* @returns {Object} Provider info
*/
getInfo() {
return {
...super.getInfo(),
isAuthenticated: this.isAuthenticated,
previewFeatures: this.options.previewFeatures,
defaultModel: this.options.model,
};
}
}
module.exports = { GeminiProvider };