diff --git a/.gitignore b/.gitignore index 59cf700e..fc3d5652 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,5 @@ node_modules .automaker/ /.automaker/* /.automaker/ + +/old \ No newline at end of file diff --git a/apps/server/.env.example b/apps/server/.env.example index 6ce580b1..e9cf96dd 100644 --- a/apps/server/.env.example +++ b/apps/server/.env.example @@ -38,8 +38,12 @@ DATA_DIR=./data # OPTIONAL - Additional AI Providers # ============================================ -# OpenAI API key (for Codex CLI support) +# OpenAI API key for GPT/Codex models (gpt-5.2, gpt-5.1-codex, etc.) +# Codex CLI must be installed: npm install -g @openai/codex@latest OPENAI_API_KEY= +# Optional: Override Codex CLI path (auto-detected by default) +# CODEX_CLI_PATH=/usr/local/bin/codex + # Google API key (for future Gemini support) GOOGLE_API_KEY= diff --git a/apps/server/src/lib/subprocess-manager.ts b/apps/server/src/lib/subprocess-manager.ts new file mode 100644 index 00000000..ddfc7485 --- /dev/null +++ b/apps/server/src/lib/subprocess-manager.ts @@ -0,0 +1,202 @@ +/** + * Subprocess management utilities for CLI providers + */ + +import { spawn, type ChildProcess } from "child_process"; +import readline from "readline"; + +export interface SubprocessOptions { + command: string; + args: string[]; + cwd: string; + env?: Record; + abortController?: AbortController; + timeout?: number; // Milliseconds of no output before timeout +} + +export interface SubprocessResult { + stdout: string; + stderr: string; + exitCode: number | null; +} + +/** + * Spawns a subprocess and streams JSONL output line-by-line + */ +export async function* spawnJSONLProcess( + options: SubprocessOptions +): AsyncGenerator { + const { command, args, cwd, env, abortController, timeout = 30000 } = options; + + const processEnv = { + ...process.env, + ...env, + }; + + const childProcess: ChildProcess = spawn(command, args, { + cwd, + env: processEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stderrOutput = ""; + let lastOutputTime = Date.now(); + let timeoutHandle: NodeJS.Timeout | null = null; + + // Collect stderr for error reporting + if (childProcess.stderr) { + childProcess.stderr.on("data", (data: Buffer) => { + const text = data.toString(); + stderrOutput += text; + console.error(`[SubprocessManager] stderr: ${text}`); + }); + } + + // Setup timeout detection + const resetTimeout = () => { + lastOutputTime = Date.now(); + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + timeoutHandle = setTimeout(() => { + const elapsed = Date.now() - lastOutputTime; + if (elapsed >= timeout) { + console.error( + `[SubprocessManager] Process timeout: no output for ${timeout}ms` + ); + childProcess.kill("SIGTERM"); + } + }, timeout); + }; + + resetTimeout(); + + // Setup abort handling + if (abortController) { + abortController.signal.addEventListener("abort", () => { + console.log("[SubprocessManager] Abort signal received, killing process"); + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + childProcess.kill("SIGTERM"); + }); + } + + // Parse stdout as JSONL (one JSON object per line) + if (childProcess.stdout) { + const rl = readline.createInterface({ + input: childProcess.stdout, + crlfDelay: Infinity, + }); + + try { + for await (const line of rl) { + resetTimeout(); + + if (!line.trim()) continue; + + try { + const parsed = JSON.parse(line); + yield parsed; + } catch (parseError) { + console.error( + `[SubprocessManager] Failed to parse JSONL line: ${line}`, + parseError + ); + // Yield error but continue processing + yield { + type: "error", + error: `Failed to parse output: ${line}`, + }; + } + } + } catch (error) { + console.error("[SubprocessManager] Error reading stdout:", error); + throw error; + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + } + } + + // Wait for process to exit + const exitCode = await new Promise((resolve) => { + childProcess.on("exit", (code) => { + resolve(code); + }); + + childProcess.on("error", (error) => { + console.error("[SubprocessManager] Process error:", error); + resolve(null); + }); + }); + + // Handle non-zero exit codes + if (exitCode !== 0 && exitCode !== null) { + const errorMessage = stderrOutput || `Process exited with code ${exitCode}`; + console.error(`[SubprocessManager] Process failed: ${errorMessage}`); + yield { + type: "error", + error: errorMessage, + }; + } + + // Process completed successfully + if (exitCode === 0 && !stderrOutput) { + // Success - no logging needed + } +} + +/** + * Spawns a subprocess and collects all output + */ +export async function spawnProcess( + options: SubprocessOptions +): Promise { + const { command, args, cwd, env, abortController } = options; + + const processEnv = { + ...process.env, + ...env, + }; + + return new Promise((resolve, reject) => { + const childProcess = spawn(command, args, { + cwd, + env: processEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + if (childProcess.stdout) { + childProcess.stdout.on("data", (data: Buffer) => { + stdout += data.toString(); + }); + } + + if (childProcess.stderr) { + childProcess.stderr.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + } + + // Setup abort handling + if (abortController) { + abortController.signal.addEventListener("abort", () => { + childProcess.kill("SIGTERM"); + reject(new Error("Process aborted")); + }); + } + + childProcess.on("exit", (code) => { + resolve({ stdout, stderr, exitCode: code }); + }); + + childProcess.on("error", (error) => { + reject(error); + }); + }); +} diff --git a/apps/server/src/providers/base-provider.ts b/apps/server/src/providers/base-provider.ts new file mode 100644 index 00000000..4b483ed7 --- /dev/null +++ b/apps/server/src/providers/base-provider.ts @@ -0,0 +1,96 @@ +/** + * Abstract base class for AI model providers + */ + +import type { + ProviderConfig, + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ValidationResult, + ModelDefinition, +} from "./types.js"; + +/** + * Base provider class that all provider implementations must extend + */ +export abstract class BaseProvider { + protected config: ProviderConfig; + protected name: string; + + constructor(config: ProviderConfig = {}) { + this.config = config; + this.name = this.getName(); + } + + /** + * Get the provider name (e.g., "claude", "codex", "cursor") + */ + abstract getName(): string; + + /** + * Execute a query and stream responses + * @param options Execution options + * @returns AsyncGenerator yielding provider messages + */ + abstract executeQuery( + options: ExecuteOptions + ): AsyncGenerator; + + /** + * Detect if the provider is installed and configured + * @returns Installation status + */ + abstract detectInstallation(): Promise; + + /** + * Get available models for this provider + * @returns Array of model definitions + */ + abstract getAvailableModels(): ModelDefinition[]; + + /** + * Validate the provider configuration + * @returns Validation result + */ + validateConfig(): ValidationResult { + const errors: string[] = []; + const warnings: string[] = []; + + // Base validation (can be overridden) + if (!this.config) { + errors.push("Provider config is missing"); + } + + return { + valid: errors.length === 0, + errors, + warnings, + }; + } + + /** + * Check if the provider supports a specific feature + * @param feature Feature name (e.g., "vision", "tools", "mcp") + * @returns Whether the feature is supported + */ + supportsFeature(feature: string): boolean { + // Default implementation - override in subclasses + const commonFeatures = ["tools", "text"]; + return commonFeatures.includes(feature); + } + + /** + * Get provider configuration + */ + getConfig(): ProviderConfig { + return this.config; + } + + /** + * Update provider configuration + */ + setConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } +} diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts new file mode 100644 index 00000000..9384ad34 --- /dev/null +++ b/apps/server/src/providers/claude-provider.ts @@ -0,0 +1,202 @@ +/** + * Claude Provider - Executes queries using Claude Agent SDK + * + * Wraps the @anthropic-ai/claude-agent-sdk for seamless integration + * with the provider architecture. + */ + +import { query, type Options } from "@anthropic-ai/claude-agent-sdk"; +import { BaseProvider } from "./base-provider.js"; +import type { + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ModelDefinition, +} from "./types.js"; + +export class ClaudeProvider extends BaseProvider { + getName(): string { + return "claude"; + } + + /** + * Execute a query using Claude Agent SDK + */ + async *executeQuery(options: ExecuteOptions): AsyncGenerator { + const { + prompt, + model, + cwd, + systemPrompt, + maxTurns = 20, + allowedTools, + abortController, + conversationHistory, + } = options; + + // Build Claude SDK options + const sdkOptions: Options = { + model, + systemPrompt, + maxTurns, + cwd, + allowedTools: allowedTools || [ + "Read", + "Write", + "Edit", + "Glob", + "Grep", + "Bash", + "WebSearch", + "WebFetch", + ], + permissionMode: "acceptEdits", + sandbox: { + enabled: true, + autoAllowBashIfSandboxed: true, + }, + abortController, + }; + + // Build prompt payload with conversation history + let promptPayload: string | AsyncGenerator; + + if (conversationHistory && conversationHistory.length > 0) { + // Multi-turn conversation with history + promptPayload = (async function* () { + // Yield all previous messages first + for (const historyMsg of conversationHistory) { + yield { + type: historyMsg.role, + session_id: "", + message: { + role: historyMsg.role, + content: Array.isArray(historyMsg.content) + ? historyMsg.content + : [{ type: "text", text: historyMsg.content }], + }, + parent_tool_use_id: null, + }; + } + + // Yield current prompt + yield { + type: "user" as const, + session_id: "", + message: { + role: "user" as const, + content: Array.isArray(prompt) + ? prompt + : [{ type: "text", text: prompt }], + }, + parent_tool_use_id: null, + }; + })(); + } else if (Array.isArray(prompt)) { + // Multi-part prompt (with images) - no history + promptPayload = (async function* () { + yield { + type: "user" as const, + session_id: "", + message: { + role: "user" as const, + content: prompt, + }, + parent_tool_use_id: null, + }; + })(); + } else { + // Simple text prompt - no history + promptPayload = prompt; + } + + // Execute via Claude Agent SDK + const stream = query({ prompt: promptPayload, options: sdkOptions }); + + // Stream messages directly - they're already in the correct format + for await (const msg of stream) { + yield msg as ProviderMessage; + } + } + + /** + * Detect Claude SDK installation (always available via npm) + */ + async detectInstallation(): Promise { + // Claude SDK is always available since it's a dependency + const hasApiKey = + !!process.env.ANTHROPIC_API_KEY || !!process.env.CLAUDE_CODE_OAUTH_TOKEN; + + return { + installed: true, + method: "sdk", + hasApiKey, + authenticated: hasApiKey, + }; + } + + /** + * Get available Claude models + */ + getAvailableModels(): ModelDefinition[] { + return [ + { + id: "claude-opus-4-5-20251101", + name: "Claude Opus 4.5", + modelString: "claude-opus-4-5-20251101", + provider: "anthropic", + description: "Most capable Claude model", + contextWindow: 200000, + maxOutputTokens: 16000, + supportsVision: true, + supportsTools: true, + tier: "premium", + default: true, + }, + { + id: "claude-sonnet-4-20250514", + name: "Claude Sonnet 4", + modelString: "claude-sonnet-4-20250514", + provider: "anthropic", + description: "Balanced performance and cost", + contextWindow: 200000, + maxOutputTokens: 16000, + supportsVision: true, + supportsTools: true, + tier: "standard", + }, + { + id: "claude-3-5-sonnet-20241022", + name: "Claude 3.5 Sonnet", + modelString: "claude-3-5-sonnet-20241022", + provider: "anthropic", + description: "Fast and capable", + contextWindow: 200000, + maxOutputTokens: 8000, + supportsVision: true, + supportsTools: true, + tier: "standard", + }, + { + id: "claude-3-5-haiku-20241022", + name: "Claude 3.5 Haiku", + modelString: "claude-3-5-haiku-20241022", + provider: "anthropic", + description: "Fastest Claude model", + contextWindow: 200000, + maxOutputTokens: 8000, + supportsVision: true, + supportsTools: true, + tier: "basic", + }, + ]; + } + + /** + * Check if the provider supports a specific feature + */ + supportsFeature(feature: string): boolean { + const supportedFeatures = ["tools", "text", "vision", "thinking"]; + return supportedFeatures.includes(feature); + } +} diff --git a/apps/server/src/providers/codex-cli-detector.ts b/apps/server/src/providers/codex-cli-detector.ts new file mode 100644 index 00000000..21445b46 --- /dev/null +++ b/apps/server/src/providers/codex-cli-detector.ts @@ -0,0 +1,408 @@ +/** + * Codex CLI Detector - Checks if OpenAI Codex CLI is installed + * + * Codex CLI is OpenAI's agent CLI tool that allows users to use + * GPT-5.1/5.2 Codex models for code generation and agentic tasks. + */ + +import { execSync } from "child_process"; +import fs from "fs"; +import path from "path"; +import os from "os"; +import type { InstallationStatus } from "./types.js"; + +export class CodexCliDetector { + /** + * Get the path to Codex config directory + */ + static getConfigDir(): string { + return path.join(os.homedir(), ".codex"); + } + + /** + * Get the path to Codex auth file + */ + static getAuthPath(): string { + return path.join(this.getConfigDir(), "auth.json"); + } + + /** + * Check Codex authentication status + */ + static checkAuth(): { + authenticated: boolean; + method: string; + hasAuthFile?: boolean; + hasEnvKey?: boolean; + authPath?: string; + error?: string; + } { + try { + const authPath = this.getAuthPath(); + const envApiKey = process.env.OPENAI_API_KEY; + + // Try to verify authentication using codex CLI command if available + try { + const detection = this.detectCodexInstallation(); + if (detection.installed && detection.path) { + try { + // Use 2>&1 to capture both stdout and stderr + const statusOutput = execSync( + `"${detection.path}" login status 2>&1`, + { + encoding: "utf-8", + timeout: 5000, + } + ).trim(); + + // Check if the output indicates logged in status + if ( + statusOutput && + (statusOutput.includes("Logged in") || statusOutput.includes("Authenticated")) + ) { + return { + authenticated: true, + method: "cli_verified", + hasAuthFile: fs.existsSync(authPath), + hasEnvKey: !!envApiKey, + authPath, + }; + } + } catch (statusError) { + // status command failed, continue with file-based check + } + } + } catch (verifyError) { + // CLI verification failed, continue with file-based check + } + + // Check if auth file exists + if (fs.existsSync(authPath)) { + try { + const content = fs.readFileSync(authPath, "utf-8"); + const auth: any = JSON.parse(content); + + // Check for token object structure + if (auth.token && typeof auth.token === "object") { + const token = auth.token; + if ( + token.Id_token || + token.access_token || + token.refresh_token || + token.id_token + ) { + return { + authenticated: true, + method: "cli_tokens", + hasAuthFile: true, + hasEnvKey: !!envApiKey, + authPath, + }; + } + } + + // Check for tokens at root level + if ( + auth.access_token || + auth.refresh_token || + auth.Id_token || + auth.id_token + ) { + return { + authenticated: true, + method: "cli_tokens", + hasAuthFile: true, + hasEnvKey: !!envApiKey, + authPath, + }; + } + + // Check for API key fields + if (auth.api_key || auth.openai_api_key || auth.apiKey) { + return { + authenticated: true, + method: "auth_file", + hasAuthFile: true, + hasEnvKey: !!envApiKey, + authPath, + }; + } + } catch (error) { + return { + authenticated: false, + method: "none", + hasAuthFile: false, + hasEnvKey: !!envApiKey, + authPath, + }; + } + } + + // Environment variable override + if (envApiKey) { + return { + authenticated: true, + method: "env", + hasAuthFile: fs.existsSync(authPath), + hasEnvKey: true, + authPath, + }; + } + + return { + authenticated: false, + method: "none", + hasAuthFile: fs.existsSync(authPath), + hasEnvKey: false, + authPath, + }; + } catch (error) { + return { + authenticated: false, + method: "none", + error: (error as Error).message, + }; + } + } + + /** + * Check if Codex CLI is installed and accessible + */ + static detectCodexInstallation(): InstallationStatus & { + hasApiKey?: boolean; + } { + try { + // Method 1: Check if 'codex' command is in PATH + try { + const codexPath = execSync("which codex 2>/dev/null", { + encoding: "utf-8", + }).trim(); + if (codexPath) { + const version = this.getCodexVersion(codexPath); + return { + installed: true, + path: codexPath, + version: version || undefined, + method: "cli", + }; + } + } catch (error) { + // CLI not in PATH, continue checking other methods + } + + // Method 2: Check for npm global installation + try { + const npmListOutput = execSync( + "npm list -g @openai/codex --depth=0 2>/dev/null", + { encoding: "utf-8" } + ); + if (npmListOutput && npmListOutput.includes("@openai/codex")) { + // Get the path from npm bin + const npmBinPath = execSync("npm bin -g", { + encoding: "utf-8", + }).trim(); + const codexPath = path.join(npmBinPath, "codex"); + const version = this.getCodexVersion(codexPath); + return { + installed: true, + path: codexPath, + version: version || undefined, + method: "npm", + }; + } + } catch (error) { + // npm global not found + } + + // Method 3: Check for Homebrew installation on macOS + if (process.platform === "darwin") { + try { + const brewList = execSync("brew list --formula 2>/dev/null", { + encoding: "utf-8", + }); + if (brewList.includes("codex")) { + const brewPrefixOutput = execSync("brew --prefix codex 2>/dev/null", { + encoding: "utf-8", + }).trim(); + const codexPath = path.join(brewPrefixOutput, "bin", "codex"); + const version = this.getCodexVersion(codexPath); + return { + installed: true, + path: codexPath, + version: version || undefined, + method: "brew", + }; + } + } catch (error) { + // Homebrew not found or codex not installed via brew + } + } + + // Method 4: Check Windows path + if (process.platform === "win32") { + try { + const codexPath = execSync("where codex 2>nul", { + encoding: "utf-8", + }) + .trim() + .split("\n")[0]; + if (codexPath) { + const version = this.getCodexVersion(codexPath); + return { + installed: true, + path: codexPath, + version: version || undefined, + method: "cli", + }; + } + } catch (error) { + // Not found on Windows + } + } + + // Method 5: Check common installation paths + const commonPaths = [ + path.join(os.homedir(), ".local", "bin", "codex"), + path.join(os.homedir(), ".npm-global", "bin", "codex"), + "/usr/local/bin/codex", + "/opt/homebrew/bin/codex", + ]; + + for (const checkPath of commonPaths) { + if (fs.existsSync(checkPath)) { + const version = this.getCodexVersion(checkPath); + return { + installed: true, + path: checkPath, + version: version || undefined, + method: "cli", + }; + } + } + + // Method 6: Check if OPENAI_API_KEY is set (can use Codex API directly) + if (process.env.OPENAI_API_KEY) { + return { + installed: false, + hasApiKey: true, + }; + } + + return { + installed: false, + }; + } catch (error) { + return { + installed: false, + error: (error as Error).message, + }; + } + } + + /** + * Get Codex CLI version from executable path + */ + static getCodexVersion(codexPath: string): string | null { + try { + const version = execSync(`"${codexPath}" --version 2>/dev/null`, { + encoding: "utf-8", + }).trim(); + return version || null; + } catch (error) { + return null; + } + } + + /** + * Get installation info and recommendations + */ + static getInstallationInfo(): { + status: string; + method?: string; + version?: string | null; + path?: string | null; + recommendation: string; + installCommands?: Record; + } { + const detection = this.detectCodexInstallation(); + + if (detection.installed) { + return { + status: "installed", + method: detection.method, + version: detection.version, + path: detection.path, + recommendation: + detection.method === "cli" + ? "Using Codex CLI - ready for GPT-5.1/5.2 Codex models" + : `Using Codex CLI via ${detection.method} - ready for GPT-5.1/5.2 Codex models`, + }; + } + + // Not installed but has API key + if (detection.hasApiKey) { + return { + status: "api_key_only", + method: "api-key-only", + recommendation: + "OPENAI_API_KEY detected but Codex CLI not installed. Install Codex CLI for full agentic capabilities.", + installCommands: this.getInstallCommands(), + }; + } + + return { + status: "not_installed", + recommendation: + "Install OpenAI Codex CLI to use GPT-5.1/5.2 Codex models for agentic tasks", + installCommands: this.getInstallCommands(), + }; + } + + /** + * Get installation commands for different platforms + */ + static getInstallCommands(): Record { + return { + npm: "npm install -g @openai/codex@latest", + macos: "brew install codex", + linux: "npm install -g @openai/codex@latest", + windows: "npm install -g @openai/codex@latest", + }; + } + + /** + * Check if Codex CLI supports a specific model + */ + static isModelSupported(model: string): boolean { + const supportedModels = [ + "gpt-5.1-codex-max", + "gpt-5.1-codex", + "gpt-5.1-codex-mini", + "gpt-5.1", + "gpt-5.2", + ]; + return supportedModels.includes(model); + } + + /** + * Get default model for Codex CLI + */ + static getDefaultModel(): string { + return "gpt-5.2"; + } + + /** + * Get comprehensive installation info including auth status + */ + static getFullStatus() { + const installation = this.detectCodexInstallation(); + const auth = this.checkAuth(); + const info = this.getInstallationInfo(); + + return { + ...info, + auth, + installation, + }; + } +} diff --git a/apps/server/src/providers/codex-config-manager.ts b/apps/server/src/providers/codex-config-manager.ts new file mode 100644 index 00000000..12c3259b --- /dev/null +++ b/apps/server/src/providers/codex-config-manager.ts @@ -0,0 +1,355 @@ +/** + * Codex TOML Configuration Manager + * + * Manages Codex CLI's TOML configuration file to add/update MCP server settings. + * Codex CLI looks for config at: + * - ~/.codex/config.toml (user-level) + * - .codex/config.toml (project-level, takes precedence) + */ + +import fs from "fs/promises"; +import path from "path"; +import os from "os"; + +interface McpServerConfig { + command: string; + args?: string[]; + env?: Record; + startup_timeout_sec?: number; + tool_timeout_sec?: number; + enabled_tools?: string[]; +} + +interface CodexConfig { + experimental_use_rmcp_client?: boolean; + mcp_servers?: Record; + [key: string]: any; +} + +export class CodexConfigManager { + private userConfigPath: string; + private projectConfigPath: string | null = null; + + constructor() { + this.userConfigPath = path.join(os.homedir(), ".codex", "config.toml"); + } + + /** + * Set the project path for project-level config + */ + setProjectPath(projectPath: string): void { + this.projectConfigPath = path.join(projectPath, ".codex", "config.toml"); + } + + /** + * Get the effective config path (project-level if exists, otherwise user-level) + */ + async getConfigPath(): Promise { + if (this.projectConfigPath) { + try { + await fs.access(this.projectConfigPath); + return this.projectConfigPath; + } catch (e) { + // Project config doesn't exist, fall back to user config + } + } + + // Ensure user config directory exists + const userConfigDir = path.dirname(this.userConfigPath); + try { + await fs.mkdir(userConfigDir, { recursive: true }); + } catch (e) { + // Directory might already exist + } + + return this.userConfigPath; + } + + /** + * Read existing TOML config (simple parser for our needs) + */ + async readConfig(configPath: string): Promise { + try { + const content = await fs.readFile(configPath, "utf-8"); + return this.parseToml(content); + } catch (e: any) { + if (e.code === "ENOENT") { + return {}; + } + throw e; + } + } + + /** + * Simple TOML parser for our specific use case + * This is a minimal parser that handles the MCP server config structure + */ + parseToml(content: string): CodexConfig { + const config: CodexConfig = {}; + let currentSection: string | null = null; + let currentSubsection: string | null = null; + + const lines = content.split("\n"); + + for (const line of lines) { + const trimmed = line.trim(); + + // Skip comments and empty lines + if (!trimmed || trimmed.startsWith("#")) { + continue; + } + + // Section header: [section] + const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/); + if (sectionMatch) { + const sectionName = sectionMatch[1]; + const parts = sectionName.split("."); + + if (parts.length === 1) { + currentSection = parts[0]; + currentSubsection = null; + if (!config[currentSection]) { + config[currentSection] = {}; + } + } else if (parts.length === 2) { + currentSection = parts[0]; + currentSubsection = parts[1]; + if (!config[currentSection]) { + config[currentSection] = {}; + } + if (!config[currentSection][currentSubsection]) { + config[currentSection][currentSubsection] = {}; + } + } + continue; + } + + // Key-value pair: key = value + const kvMatch = trimmed.match(/^([^=]+)=(.+)$/); + if (kvMatch) { + const key = kvMatch[1].trim(); + let value: any = kvMatch[2].trim(); + + // Remove quotes if present + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + + // Parse boolean + if (value === "true") value = true; + else if (value === "false") value = false; + // Parse number + else if (/^-?\d+$/.test(value)) value = parseInt(value, 10); + else if (/^-?\d+\.\d+$/.test(value)) value = parseFloat(value); + + if (currentSubsection && currentSection) { + if (!config[currentSection][currentSubsection]) { + config[currentSection][currentSubsection] = {}; + } + config[currentSection][currentSubsection][key] = value; + } else if (currentSection) { + if (!config[currentSection]) { + config[currentSection] = {}; + } + config[currentSection][key] = value; + } else { + config[key] = value; + } + } + } + + return config; + } + + /** + * Configure the automaker-tools MCP server + */ + async configureMcpServer( + projectPath: string, + mcpServerScriptPath: string + ): Promise { + this.setProjectPath(projectPath); + const configPath = await this.getConfigPath(); + + // Read existing config + const config = await this.readConfig(configPath); + + // Ensure mcp_servers section exists + if (!config.mcp_servers) { + config.mcp_servers = {}; + } + + // Configure automaker-tools server + config.mcp_servers["automaker-tools"] = { + command: "node", + args: [mcpServerScriptPath], + env: { + AUTOMAKER_PROJECT_PATH: projectPath, + }, + startup_timeout_sec: 10, + tool_timeout_sec: 60, + enabled_tools: ["UpdateFeatureStatus"], + }; + + // Ensure experimental_use_rmcp_client is enabled (if needed) + if (!config.experimental_use_rmcp_client) { + config.experimental_use_rmcp_client = true; + } + + // Write config back + await this.writeConfig(configPath, config); + + console.log( + `[CodexConfigManager] Configured automaker-tools MCP server in ${configPath}` + ); + return configPath; + } + + /** + * Write config to TOML file + */ + async writeConfig(configPath: string, config: CodexConfig): Promise { + let content = ""; + + // Write top-level keys first (preserve existing non-MCP config) + for (const [key, value] of Object.entries(config)) { + if (key === "mcp_servers" || key === "experimental_use_rmcp_client") { + continue; // Handle these separately + } + if (typeof value !== "object") { + content += `${key} = ${this.formatValue(value)}\n`; + } + } + + // Write experimental flag if enabled + if (config.experimental_use_rmcp_client) { + if (content && !content.endsWith("\n\n")) { + content += "\n"; + } + content += `experimental_use_rmcp_client = true\n`; + } + + // Write mcp_servers section + if (config.mcp_servers && Object.keys(config.mcp_servers).length > 0) { + if (content && !content.endsWith("\n\n")) { + content += "\n"; + } + + for (const [serverName, serverConfig] of Object.entries( + config.mcp_servers + )) { + content += `\n[mcp_servers.${serverName}]\n`; + + // Write command first + if (serverConfig.command) { + content += `command = "${this.escapeTomlString(serverConfig.command)}"\n`; + } + + // Write args + if (serverConfig.args && Array.isArray(serverConfig.args)) { + const argsStr = serverConfig.args + .map((a) => `"${this.escapeTomlString(a)}"`) + .join(", "); + content += `args = [${argsStr}]\n`; + } + + // Write timeouts (must be before env subsection) + if (serverConfig.startup_timeout_sec !== undefined) { + content += `startup_timeout_sec = ${serverConfig.startup_timeout_sec}\n`; + } + + if (serverConfig.tool_timeout_sec !== undefined) { + content += `tool_timeout_sec = ${serverConfig.tool_timeout_sec}\n`; + } + + // Write enabled_tools (must be before env subsection - at server level, not env level) + if (serverConfig.enabled_tools && Array.isArray(serverConfig.enabled_tools)) { + const toolsStr = serverConfig.enabled_tools + .map((t) => `"${this.escapeTomlString(t)}"`) + .join(", "); + content += `enabled_tools = [${toolsStr}]\n`; + } + + // Write env section last (as a separate subsection) + if ( + serverConfig.env && + typeof serverConfig.env === "object" && + Object.keys(serverConfig.env).length > 0 + ) { + content += `\n[mcp_servers.${serverName}.env]\n`; + for (const [envKey, envValue] of Object.entries(serverConfig.env)) { + content += `${envKey} = "${this.escapeTomlString(String(envValue))}"\n`; + } + } + } + } + + // Ensure directory exists + const configDir = path.dirname(configPath); + await fs.mkdir(configDir, { recursive: true }); + + // Write file + await fs.writeFile(configPath, content, "utf-8"); + } + + /** + * Escape special characters in TOML strings + */ + escapeTomlString(str: string): string { + return str + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/\t/g, "\\t"); + } + + /** + * Format a value for TOML output + */ + formatValue(value: any): string { + if (typeof value === "string") { + const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + return `"${escaped}"`; + } else if (typeof value === "boolean") { + return value.toString(); + } else if (typeof value === "number") { + return value.toString(); + } + return `"${String(value)}"`; + } + + /** + * Remove automaker-tools MCP server configuration + */ + async removeMcpServer(projectPath: string): Promise { + this.setProjectPath(projectPath); + const configPath = await this.getConfigPath(); + + try { + const config = await this.readConfig(configPath); + + if (config.mcp_servers && config.mcp_servers["automaker-tools"]) { + delete config.mcp_servers["automaker-tools"]; + + // If no more MCP servers, remove the section + if (Object.keys(config.mcp_servers).length === 0) { + delete config.mcp_servers; + } + + await this.writeConfig(configPath, config); + console.log( + `[CodexConfigManager] Removed automaker-tools MCP server from ${configPath}` + ); + } + } catch (e) { + console.error(`[CodexConfigManager] Error removing MCP server config:`, e); + } + } +} + +// Export singleton instance +export const codexConfigManager = new CodexConfigManager(); diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts new file mode 100644 index 00000000..0c608def --- /dev/null +++ b/apps/server/src/providers/codex-provider.ts @@ -0,0 +1,550 @@ +/** + * Codex Provider - Executes queries using OpenAI Codex CLI + * + * Spawns Codex CLI as a subprocess and converts JSONL output to + * Claude SDK-compatible message format for seamless integration. + */ + +import { BaseProvider } from "./base-provider.js"; +import { CodexCliDetector } from "./codex-cli-detector.js"; +import { codexConfigManager } from "./codex-config-manager.js"; +import { spawnJSONLProcess } from "../lib/subprocess-manager.js"; +import type { + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ModelDefinition, + ContentBlock, +} from "./types.js"; + +// Codex event types +const CODEX_EVENT_TYPES = { + THREAD_STARTED: "thread.started", + THREAD_COMPLETED: "thread.completed", + ITEM_STARTED: "item.started", + ITEM_COMPLETED: "item.completed", + TURN_STARTED: "turn.started", + ERROR: "error", +}; + +interface CodexEvent { + type: string; + data?: any; + item?: any; + thread_id?: string; + message?: string; +} + +export class CodexProvider extends BaseProvider { + getName(): string { + return "codex"; + } + + /** + * Execute a query using Codex CLI + */ + async *executeQuery(options: ExecuteOptions): AsyncGenerator { + const { + prompt, + model = "gpt-5.2", + cwd, + systemPrompt, + mcpServers, + abortController, + conversationHistory, + } = options; + + // Find Codex CLI path + const codexPath = this.findCodexPath(); + if (!codexPath) { + yield { + type: "error", + error: + "Codex CLI not found. Please install it with: npm install -g @openai/codex@latest", + }; + return; + } + + // Configure MCP server if provided + if (mcpServers && mcpServers["automaker-tools"]) { + try { + const mcpServerScriptPath = await this.getMcpServerPath(); + if (mcpServerScriptPath) { + await codexConfigManager.configureMcpServer(cwd, mcpServerScriptPath); + } + } catch (error) { + console.error("[CodexProvider] Failed to configure MCP server:", error); + // Continue execution even if MCP config fails + } + } + + // Build combined prompt with conversation history + // Codex CLI doesn't support native conversation history or images, so we extract text + let combinedPrompt = ""; + + if (typeof prompt === "string") { + combinedPrompt = prompt; + } else if (Array.isArray(prompt)) { + // Extract text from content blocks (ignore images - Codex CLI doesn't support vision) + combinedPrompt = prompt + .filter(block => block.type === "text") + .map(block => block.text || "") + .join("\n"); + } + + // Add system prompt first + if (systemPrompt) { + combinedPrompt = `${systemPrompt}\n\n---\n\n${combinedPrompt}`; + } + + // Add conversation history + if (conversationHistory && conversationHistory.length > 0) { + let historyText = "Previous conversation:\n\n"; + for (const msg of conversationHistory) { + const contentText = typeof msg.content === "string" + ? msg.content + : msg.content.map(c => c.text || "").join(""); + historyText += `${msg.role === "user" ? "User" : "Assistant"}: ${contentText}\n\n`; + } + combinedPrompt = `${historyText}---\n\nCurrent request:\n${combinedPrompt}`; + } + + // Build command arguments + const args = this.buildArgs({ prompt: combinedPrompt, model }); + + // Check authentication - either API key or CLI login + const auth = CodexCliDetector.checkAuth(); + const hasApiKey = this.config.apiKey || process.env.OPENAI_API_KEY; + + if (!auth.authenticated && !hasApiKey) { + yield { + type: "error", + error: + "Codex CLI is not authenticated. Please run 'codex login' or set OPENAI_API_KEY environment variable.", + }; + return; + } + + // Prepare environment variables (API key is optional if using CLI auth) + const env = { + ...this.config.env, + ...(hasApiKey && { OPENAI_API_KEY: hasApiKey }), + }; + + // Spawn the Codex process and stream JSONL output + try { + const stream = spawnJSONLProcess({ + command: codexPath, + args, + cwd, + env, + abortController, + timeout: 30000, // 30s timeout for no output + }); + + for await (const event of stream) { + const converted = this.convertToProviderFormat(event as CodexEvent); + if (converted) { + yield converted; + } + } + + // Yield completion event + yield { + type: "result", + subtype: "success", + result: "", + }; + } catch (error) { + console.error("[CodexProvider] Execution error:", error); + yield { + type: "error", + error: (error as Error).message, + }; + } + } + + /** + * Convert Codex JSONL event to Provider message format (Claude SDK compatible) + */ + private convertToProviderFormat(event: CodexEvent): ProviderMessage | null { + const { type, data, item, thread_id } = event; + + switch (type) { + case CODEX_EVENT_TYPES.THREAD_STARTED: + case "thread.started": + // Session initialization - not needed for provider format + return null; + + case CODEX_EVENT_TYPES.ITEM_COMPLETED: + case "item.completed": + return this.convertItemCompleted(item || data); + + case CODEX_EVENT_TYPES.ITEM_STARTED: + case "item.started": + // Item started events can show tool usage + const startedItem = item || data; + if ( + startedItem?.type === "command_execution" && + startedItem?.command + ) { + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + name: "bash", + input: { command: startedItem.command }, + }, + ], + }, + }; + } + // Handle todo_list started + if (startedItem?.type === "todo_list" && startedItem?.items) { + const todos = startedItem.items || []; + const todoText = todos + .map((t: any, i: number) => `${i + 1}. ${t.text || t}`) + .join("\n"); + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "text", + text: `**Todo List:**\n${todoText}`, + }, + ], + }, + }; + } + return null; + + case "item.updated": + // Handle updated items (like todo list updates) + const updatedItem = item || data; + if (updatedItem?.type === "todo_list" && updatedItem?.items) { + const todos = updatedItem.items || []; + const todoText = todos + .map((t: any, i: number) => { + const status = t.status === "completed" ? "✓" : " "; + return `${i + 1}. [${status}] ${t.text || t}`; + }) + .join("\n"); + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "text", + text: `**Updated Todo List:**\n${todoText}`, + }, + ], + }, + }; + } + return null; + + case CODEX_EVENT_TYPES.THREAD_COMPLETED: + case "thread.completed": + return { + type: "result", + subtype: "success", + result: "", + }; + + case CODEX_EVENT_TYPES.ERROR: + case "error": + return { + type: "error", + error: + data?.message || + item?.message || + event.message || + "Unknown error from Codex CLI", + }; + + case "turn.started": + case "turn.completed": + // Turn markers - not needed for provider format + return null; + + default: + return null; + } + } + + /** + * Convert item.completed event to Provider format + */ + private convertItemCompleted(item: any): ProviderMessage | null { + if (!item) { + return null; + } + + const itemType = item.type || item.item_type; + + switch (itemType) { + case "reasoning": + // Thinking/reasoning output + const reasoningText = item.text || item.content || ""; + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "thinking", + thinking: reasoningText, + }, + ], + }, + }; + + case "agent_message": + case "message": + // Assistant text message + const messageText = item.content || item.text || ""; + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "text", + text: messageText, + }, + ], + }, + }; + + case "command_execution": + // Command execution - show both the command and its output + const command = item.command || ""; + const output = item.aggregated_output || item.output || ""; + + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "text", + text: `\`\`\`bash\n${command}\n\`\`\`\n\n${output}`, + }, + ], + }, + }; + + case "tool_use": + // Tool use + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + name: item.tool || item.command || "unknown", + input: item.input || item.args || {}, + }, + ], + }, + }; + + case "tool_result": + // Tool result + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_result", + tool_use_id: item.tool_use_id, + content: item.output || item.result, + }, + ], + }, + }; + + case "todo_list": + // Todo list - convert to text format + const todos = item.items || []; + const todoText = todos + .map((t: any, i: number) => `${i + 1}. ${t.text || t}`) + .join("\n"); + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "text", + text: `**Todo List:**\n${todoText}`, + }, + ], + }, + }; + + case "file_change": + // File changes - show what files were modified + const changes = item.changes || []; + const changeText = changes + .map((c: any) => `- Modified: ${c.path}`) + .join("\n"); + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "text", + text: `**File Changes:**\n${changeText}`, + }, + ], + }, + }; + + default: + // Generic text output + const text = item.text || item.content || item.aggregated_output; + if (text) { + return { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "text", + text: String(text), + }, + ], + }, + }; + } + return null; + } + } + + /** + * Build command arguments for Codex CLI + */ + private buildArgs(options: { + prompt: string; + model: string; + }): string[] { + const { prompt, model } = options; + + return [ + "exec", + "--model", + model, + "--json", // JSONL output format + "--full-auto", // Non-interactive mode + prompt, // Prompt as the last argument + ]; + } + + /** + * Find Codex CLI executable path + */ + private findCodexPath(): string | null { + // Check config override + if (this.config.cliPath) { + return this.config.cliPath; + } + + // Check environment variable override + if (process.env.CODEX_CLI_PATH) { + return process.env.CODEX_CLI_PATH; + } + + // Auto-detect + const detection = CodexCliDetector.detectCodexInstallation(); + return detection.path || "codex"; + } + + /** + * Get MCP server script path + */ + private async getMcpServerPath(): Promise { + // TODO: Implement MCP server path resolution + // For now, return null - MCP support is optional + return null; + } + + /** + * Detect Codex CLI installation + */ + async detectInstallation(): Promise { + const detection = CodexCliDetector.detectCodexInstallation(); + const auth = CodexCliDetector.checkAuth(); + + return { + installed: detection.installed, + path: detection.path, + version: detection.version, + method: detection.method, + hasApiKey: auth.hasEnvKey || auth.authenticated, + authenticated: auth.authenticated, + }; + } + + /** + * Get available Codex models + */ + getAvailableModels(): ModelDefinition[] { + return [ + { + id: "gpt-5.2", + name: "GPT-5.2 (Codex)", + modelString: "gpt-5.2", + provider: "openai-codex", + description: "Latest Codex model for agentic code generation", + contextWindow: 256000, + maxOutputTokens: 32768, + supportsVision: true, + supportsTools: true, + tier: "premium", + default: true, + }, + { + id: "gpt-5.1-codex-max", + name: "GPT-5.1 Codex Max", + modelString: "gpt-5.1-codex-max", + provider: "openai-codex", + description: "Maximum capability Codex model", + contextWindow: 256000, + maxOutputTokens: 32768, + supportsVision: true, + supportsTools: true, + tier: "premium", + }, + { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + modelString: "gpt-5.1-codex", + provider: "openai-codex", + description: "Standard Codex model", + contextWindow: 256000, + maxOutputTokens: 32768, + supportsVision: true, + supportsTools: true, + tier: "standard", + }, + ]; + } + + /** + * Check if the provider supports a specific feature + */ + supportsFeature(feature: string): boolean { + const supportedFeatures = ["tools", "text", "vision", "mcp", "cli"]; + return supportedFeatures.includes(feature); + } +} diff --git a/apps/server/src/providers/provider-factory.ts b/apps/server/src/providers/provider-factory.ts new file mode 100644 index 00000000..e39eb65f --- /dev/null +++ b/apps/server/src/providers/provider-factory.ts @@ -0,0 +1,126 @@ +/** + * Provider Factory - Routes model IDs to the appropriate provider + * + * This factory implements model-based routing to automatically select + * the correct provider based on the model string. This makes adding + * new providers (Cursor, OpenCode, etc.) trivial - just add one line. + */ + +import { BaseProvider } from "./base-provider.js"; +import { ClaudeProvider } from "./claude-provider.js"; +import { CodexProvider } from "./codex-provider.js"; +import type { InstallationStatus } from "./types.js"; + +export class ProviderFactory { + /** + * Get the appropriate provider for a given model ID + * + * @param modelId Model identifier (e.g., "claude-opus-4-5-20251101", "gpt-5.2", "cursor-fast") + * @returns Provider instance for the model + */ + static getProviderForModel(modelId: string): BaseProvider { + const lowerModel = modelId.toLowerCase(); + + // OpenAI/Codex models (gpt-*, o1, o3, etc.) + if (lowerModel.startsWith("gpt-") || lowerModel.startsWith("o")) { + return new CodexProvider(); + } + + // Claude models (claude-*, opus, sonnet, haiku) + if ( + lowerModel.startsWith("claude-") || + ["haiku", "sonnet", "opus"].includes(lowerModel) + ) { + return new ClaudeProvider(); + } + + // Future providers: + // if (lowerModel.startsWith("cursor-")) { + // return new CursorProvider(); + // } + // if (lowerModel.startsWith("opencode-")) { + // return new OpenCodeProvider(); + // } + + // Default to Claude for unknown models + console.warn( + `[ProviderFactory] Unknown model prefix for "${modelId}", defaulting to Claude` + ); + return new ClaudeProvider(); + } + + /** + * Get all available providers + */ + static getAllProviders(): BaseProvider[] { + return [ + new ClaudeProvider(), + new CodexProvider(), + // Future providers... + ]; + } + + /** + * Check installation status for all providers + * + * @returns Map of provider name to installation status + */ + static async checkAllProviders(): Promise< + Record + > { + const providers = this.getAllProviders(); + const statuses: Record = {}; + + for (const provider of providers) { + const name = provider.getName(); + const status = await provider.detectInstallation(); + statuses[name] = status; + } + + return statuses; + } + + /** + * Get provider by name (for direct access if needed) + * + * @param name Provider name (e.g., "claude", "codex", "cursor") + * @returns Provider instance or null if not found + */ + static getProviderByName(name: string): BaseProvider | null { + const lowerName = name.toLowerCase(); + + switch (lowerName) { + case "claude": + case "anthropic": + return new ClaudeProvider(); + + case "codex": + case "openai": + return new CodexProvider(); + + // Future providers: + // case "cursor": + // return new CursorProvider(); + // case "opencode": + // return new OpenCodeProvider(); + + default: + return null; + } + } + + /** + * Get all available models from all providers + */ + static getAllAvailableModels() { + const providers = this.getAllProviders(); + const allModels = []; + + for (const provider of providers) { + const models = provider.getAvailableModels(); + allModels.push(...models); + } + + return allModels; + } +} diff --git a/apps/server/src/providers/types.ts b/apps/server/src/providers/types.ts new file mode 100644 index 00000000..24bd41a8 --- /dev/null +++ b/apps/server/src/providers/types.ts @@ -0,0 +1,103 @@ +/** + * Shared types for AI model providers + */ + +/** + * Configuration for a provider instance + */ +export interface ProviderConfig { + apiKey?: string; + cliPath?: string; + env?: Record; +} + +/** + * Message in conversation history + */ +export interface ConversationMessage { + role: "user" | "assistant"; + content: string | Array<{ type: string; text?: string; source?: object }>; +} + +/** + * Options for executing a query via a provider + */ +export interface ExecuteOptions { + prompt: string | Array<{ type: string; text?: string; source?: object }>; + model: string; + cwd: string; + systemPrompt?: string; + maxTurns?: number; + allowedTools?: string[]; + mcpServers?: Record; + abortController?: AbortController; + conversationHistory?: ConversationMessage[]; // Previous messages for context +} + +/** + * Content block in a provider message (matches Claude SDK format) + */ +export interface ContentBlock { + type: "text" | "tool_use" | "thinking" | "tool_result"; + text?: string; + thinking?: string; + name?: string; + input?: unknown; + tool_use_id?: string; + content?: string; +} + +/** + * Message returned by a provider (matches Claude SDK streaming format) + */ +export interface ProviderMessage { + type: "assistant" | "user" | "error" | "result"; + subtype?: "success" | "error"; + session_id?: string; + message?: { + role: "user" | "assistant"; + content: ContentBlock[]; + }; + result?: string; + error?: string; + parent_tool_use_id?: string | null; +} + +/** + * Installation status for a provider + */ +export interface InstallationStatus { + installed: boolean; + path?: string; + version?: string; + method?: "cli" | "npm" | "brew" | "sdk"; + hasApiKey?: boolean; + authenticated?: boolean; + error?: string; +} + +/** + * Validation result + */ +export interface ValidationResult { + valid: boolean; + errors: string[]; + warnings?: string[]; +} + +/** + * Model definition + */ +export interface ModelDefinition { + id: string; + name: string; + modelString: string; + provider: string; + description: string; + contextWindow?: number; + maxOutputTokens?: number; + supportsVision?: boolean; + supportsTools?: boolean; + tier?: "basic" | "standard" | "premium"; + default?: boolean; +} diff --git a/apps/server/src/routes/agent.ts b/apps/server/src/routes/agent.ts index 966b8916..7315125f 100644 --- a/apps/server/src/routes/agent.ts +++ b/apps/server/src/routes/agent.ts @@ -40,11 +40,12 @@ export function createAgentRoutes( // Send a message router.post("/send", async (req: Request, res: Response) => { try { - const { sessionId, message, workingDirectory, imagePaths } = req.body as { + const { sessionId, message, workingDirectory, imagePaths, model } = req.body as { sessionId: string; message: string; workingDirectory?: string; imagePaths?: string[]; + model?: string; }; if (!sessionId || !message) { @@ -61,6 +62,7 @@ export function createAgentRoutes( message, workingDirectory, imagePaths, + model, }) .catch((error) => { console.error("[Agent Route] Error sending message:", error); @@ -128,5 +130,26 @@ export function createAgentRoutes( } }); + // Set session model + router.post("/model", async (req: Request, res: Response) => { + try { + const { sessionId, model } = req.body as { + sessionId: string; + model: string; + }; + + if (!sessionId || !model) { + res.status(400).json({ success: false, error: "sessionId and model are required" }); + return; + } + + const result = await agentService.setSessionModel(sessionId, model); + res.json({ success: result }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + res.status(500).json({ success: false, error: message }); + } + }); + return router; } diff --git a/apps/server/src/routes/models.ts b/apps/server/src/routes/models.ts index 5856fac5..6738e455 100644 --- a/apps/server/src/routes/models.ts +++ b/apps/server/src/routes/models.ts @@ -3,6 +3,7 @@ */ import { Router, type Request, type Response } from "express"; +import { ProviderFactory } from "../providers/provider-factory.js"; interface ModelDefinition { id: string; @@ -93,7 +94,25 @@ export function createModelsRoutes(): Router { { id: "gpt-5.2", name: "GPT-5.2 (Codex)", - provider: "openai", + provider: "openai-codex", + contextWindow: 256000, + maxOutputTokens: 32768, + supportsVision: true, + supportsTools: true, + }, + { + id: "gpt-5.1-codex-max", + name: "GPT-5.1 Codex Max", + provider: "openai-codex", + contextWindow: 256000, + maxOutputTokens: 32768, + supportsVision: true, + supportsTools: true, + }, + { + id: "gpt-5.1-codex", + name: "GPT-5.1 Codex", + provider: "openai-codex", contextWindow: 256000, maxOutputTokens: 32768, supportsVision: true, @@ -111,15 +130,25 @@ export function createModelsRoutes(): Router { // Check provider status router.get("/providers", async (_req: Request, res: Response) => { try { - const providers: Record = { + // Get installation status from all providers + const statuses = await ProviderFactory.checkAllProviders(); + + const providers: Record = { anthropic: { - available: !!process.env.ANTHROPIC_API_KEY, - hasApiKey: !!process.env.ANTHROPIC_API_KEY, + available: statuses.claude?.installed || false, + hasApiKey: !!process.env.ANTHROPIC_API_KEY || !!process.env.CLAUDE_CODE_OAUTH_TOKEN, }, openai: { available: !!process.env.OPENAI_API_KEY, hasApiKey: !!process.env.OPENAI_API_KEY, }, + "openai-codex": { + available: statuses.codex?.installed || false, + hasApiKey: !!process.env.OPENAI_API_KEY, + cliInstalled: statuses.codex?.installed, + cliVersion: statuses.codex?.version, + cliPath: statuses.codex?.path, + }, google: { available: !!process.env.GOOGLE_API_KEY, hasApiKey: !!process.env.GOOGLE_API_KEY, diff --git a/apps/server/src/routes/sessions.ts b/apps/server/src/routes/sessions.ts index 62940895..587c88c1 100644 --- a/apps/server/src/routes/sessions.ts +++ b/apps/server/src/routes/sessions.ts @@ -46,10 +46,11 @@ export function createSessionsRoutes(agentService: AgentService): Router { // Create a new session router.post("/", async (req: Request, res: Response) => { try { - const { name, projectPath, workingDirectory } = req.body as { + const { name, projectPath, workingDirectory, model } = req.body as { name: string; projectPath?: string; workingDirectory?: string; + model?: string; }; if (!name) { @@ -60,7 +61,8 @@ export function createSessionsRoutes(agentService: AgentService): Router { const session = await agentService.createSession( name, projectPath, - workingDirectory + workingDirectory, + model ); res.json({ success: true, session }); } catch (error) { @@ -73,12 +75,13 @@ export function createSessionsRoutes(agentService: AgentService): Router { router.put("/:sessionId", async (req: Request, res: Response) => { try { const { sessionId } = req.params; - const { name, tags } = req.body as { + const { name, tags, model } = req.body as { name?: string; tags?: string[]; + model?: string; }; - const session = await agentService.updateSession(sessionId, { name, tags }); + const session = await agentService.updateSession(sessionId, { name, tags, model }); if (!session) { res.status(404).json({ success: false, error: "Session not found" }); return; diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index 09119be6..b839d452 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -1,12 +1,14 @@ /** - * Agent Service - Runs Claude agents via the Claude Agent SDK + * Agent Service - Runs AI agents via provider architecture * Manages conversation sessions and streams responses via WebSocket */ -import { query, AbortError, type Options } from "@anthropic-ai/claude-agent-sdk"; +import { AbortError } from "@anthropic-ai/claude-agent-sdk"; import path from "path"; import fs from "fs/promises"; import type { EventEmitter } from "../lib/events.js"; +import { ProviderFactory } from "../providers/provider-factory.js"; +import type { ExecuteOptions } from "../providers/types.js"; interface Message { id: string; @@ -26,6 +28,7 @@ interface Session { isRunning: boolean; abortController: AbortController | null; workingDirectory: string; + model?: string; } interface SessionMetadata { @@ -37,6 +40,7 @@ interface SessionMetadata { updatedAt: string; archived?: boolean; tags?: string[]; + model?: string; } export class AgentService { @@ -91,11 +95,13 @@ export class AgentService { message, workingDirectory, imagePaths, + model, }: { sessionId: string; message: string; workingDirectory?: string; imagePaths?: string[]; + model?: string; }) { const session = this.sessions.get(sessionId); if (!session) { @@ -106,6 +112,12 @@ export class AgentService { throw new Error("Agent is already processing a message"); } + // Update session model if provided + if (model) { + session.model = model; + await this.updateSession(sessionId, { model }); + } + // Read images and convert to base64 const images: Message["images"] = []; if (imagePaths && imagePaths.length > 0) { @@ -143,6 +155,12 @@ export class AgentService { timestamp: new Date().toISOString(), }; + // Build conversation history from existing messages BEFORE adding current message + const conversationHistory = session.messages.map((msg) => ({ + role: msg.role, + content: msg.content, + })); + session.messages.push(userMessage); session.isRunning = true; session.abortController = new AbortController(); @@ -156,11 +174,23 @@ export class AgentService { await this.saveSession(sessionId, session.messages); try { - const options: Options = { - model: "claude-opus-4-5-20251101", + // Use session model, parameter model, or default + const effectiveModel = model || session.model || "claude-opus-4-5-20251101"; + + // Get provider for this model + const provider = ProviderFactory.getProviderForModel(effectiveModel); + + console.log( + `[AgentService] Using provider "${provider.getName()}" for model "${effectiveModel}"` + ); + + // Build options for provider + const options: ExecuteOptions = { + prompt: "", // Will be set below based on images + model: effectiveModel, + cwd: workingDirectory || session.workingDirectory, systemPrompt: this.getSystemPrompt(), maxTurns: 20, - cwd: workingDirectory || session.workingDirectory, allowedTools: [ "Read", "Write", @@ -171,23 +201,28 @@ export class AgentService { "WebSearch", "WebFetch", ], - permissionMode: "acceptEdits", - sandbox: { - enabled: true, - autoAllowBashIfSandboxed: true, - }, abortController: session.abortController!, + conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, }; // Build prompt content let promptContent: string | Array<{ type: string; text?: string; source?: object }> = message; + // Append image paths to prompt text (like old implementation) if (imagePaths && imagePaths.length > 0) { + let enhancedMessage = message; + + // Append image file paths to the message text + enhancedMessage += "\n\nAttached images:\n"; + for (const imagePath of imagePaths) { + enhancedMessage += `- ${imagePath}\n`; + } + const contentBlocks: Array<{ type: string; text?: string; source?: object }> = []; - if (message && message.trim()) { - contentBlocks.push({ type: "text", text: message }); + if (enhancedMessage && enhancedMessage.trim()) { + contentBlocks.push({ type: "text", text: enhancedMessage }); } for (const imagePath of imagePaths) { @@ -219,25 +254,16 @@ export class AgentService { if (contentBlocks.length > 1 || contentBlocks[0]?.type === "image") { promptContent = contentBlocks; + } else { + promptContent = enhancedMessage; } } - // Build payload - const promptPayload = Array.isArray(promptContent) - ? (async function* () { - yield { - type: "user" as const, - session_id: "", - message: { - role: "user" as const, - content: promptContent, - }, - parent_tool_use_id: null, - }; - })() - : promptContent; + // Set the prompt in options + options.prompt = promptContent; - const stream = query({ prompt: promptPayload, options }); + // Execute via provider + const stream = provider.executeQuery(options); let currentAssistantMessage: Message | null = null; let responseText = ""; @@ -245,7 +271,7 @@ export class AgentService { for await (const msg of stream) { if (msg.type === "assistant") { - if (msg.message.content) { + if (msg.message?.content) { for (const block of msg.message.content) { if (block.type === "text") { responseText += block.text; @@ -270,7 +296,7 @@ export class AgentService { }); } else if (block.type === "tool_use") { const toolUse = { - name: block.name, + name: block.name || "unknown", input: block.input, }; toolUses.push(toolUse); @@ -450,7 +476,8 @@ export class AgentService { async createSession( name: string, projectPath?: string, - workingDirectory?: string + workingDirectory?: string, + model?: string ): Promise { const sessionId = this.generateId(); const metadata = await this.loadMetadata(); @@ -462,6 +489,7 @@ export class AgentService { workingDirectory: workingDirectory || projectPath || process.cwd(), createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + model, }; metadata[sessionId] = session; @@ -470,6 +498,16 @@ export class AgentService { return session; } + async setSessionModel(sessionId: string, model: string): Promise { + const session = this.sessions.get(sessionId); + if (session) { + session.model = model; + await this.updateSession(sessionId, { model }); + return true; + } + return false; + } + async updateSession( sessionId: string, updates: Partial diff --git a/apps/server/src/services/auto-mode-service.ts b/apps/server/src/services/auto-mode-service.ts index 4c44cd6e..aadca1b5 100644 --- a/apps/server/src/services/auto-mode-service.ts +++ b/apps/server/src/services/auto-mode-service.ts @@ -9,7 +9,9 @@ * - Verification and merge workflows */ -import { query, AbortError, type Options } from "@anthropic-ai/claude-agent-sdk"; +import { AbortError } from "@anthropic-ai/claude-agent-sdk"; +import { ProviderFactory } from "../providers/provider-factory.js"; +import type { ExecuteOptions } from "../providers/types.js"; import { exec } from "child_process"; import { promisify } from "util"; import path from "path"; @@ -33,6 +35,7 @@ interface Feature { priority?: number; spec?: string; model?: string; // Model to use for this feature + imagePaths?: Array; } /** @@ -237,12 +240,17 @@ export class AutoModeService { // Build the prompt const prompt = this.buildFeaturePrompt(feature); + // Extract image paths from feature + const imagePaths = feature.imagePaths?.map((img) => + typeof img === "string" ? img : img.path + ); + // Get model from feature const model = getModelString(feature); console.log(`[AutoMode] Executing feature ${featureId} with model: ${model}`); - // Run the agent with the feature's model - await this.runAgent(workDir, featureId, prompt, abortController, undefined, model); + // Run the agent with the feature's model and images + await this.runAgent(workDir, featureId, prompt, abortController, imagePaths, model); // Mark as waiting_approval for user review await this.updateFeatureStatus(projectPath, featureId, "waiting_approval"); @@ -377,7 +385,126 @@ export class AutoModeService { const model = feature ? getModelString(feature) : MODEL_MAP.opus; console.log(`[AutoMode] Follow-up for feature ${featureId} using model: ${model}`); - await this.runAgent(workDir, featureId, prompt, abortController, imagePaths, model); + // Update feature status to in_progress + await this.updateFeatureStatus(projectPath, featureId, "in_progress"); + + // Copy follow-up images to feature folder + const copiedImagePaths: string[] = []; + if (imagePaths && imagePaths.length > 0) { + const featureImagesDir = path.join( + projectPath, + ".automaker", + "features", + featureId, + "images" + ); + + await fs.mkdir(featureImagesDir, { recursive: true }); + + for (const imagePath of imagePaths) { + try { + // Get the filename from the path + const filename = path.basename(imagePath); + const destPath = path.join(featureImagesDir, filename); + + // Copy the image + await fs.copyFile(imagePath, destPath); + + // Store the relative path (like FeatureLoader does) + const relativePath = path.join( + ".automaker", + "features", + featureId, + "images", + filename + ); + copiedImagePaths.push(relativePath); + + } catch (error) { + console.error(`[AutoMode] Failed to copy follow-up image ${imagePath}:`, error); + } + } + } + + // Update feature object with new follow-up images BEFORE building prompt + if (copiedImagePaths.length > 0 && feature) { + const currentImagePaths = feature.imagePaths || []; + const newImagePaths = copiedImagePaths.map((p) => ({ + path: p, + filename: path.basename(p), + mimeType: "image/png", // Default, could be improved + })); + + feature.imagePaths = [...currentImagePaths, ...newImagePaths]; + } + + // Load previous agent output for context + const outputPath = path.join( + workDir, + ".automaker", + "features", + featureId, + "agent-output.md" + ); + let previousContext = ""; + try { + previousContext = await fs.readFile(outputPath, "utf-8"); + } catch { + // No previous context + } + + // Build follow-up prompt with context (feature now includes new images) + let followUpPrompt = prompt; + if (previousContext) { + followUpPrompt = `## Follow-up Request + +${this.buildFeaturePrompt(feature!)} + +## Previous Work +The following is the output from the previous implementation: + +${previousContext} + +--- + +## New Instructions +${prompt} + +Please continue from where you left off and address the new instructions above.`; + } + + // Combine original feature images with new follow-up images + const allImagePaths: string[] = []; + + // Add all images from feature (now includes both original and new) + if (feature?.imagePaths) { + const allPaths = feature.imagePaths.map((img) => + typeof img === "string" ? img : img.path + ); + allImagePaths.push(...allPaths); + } + + // Save updated feature.json with new images + if (copiedImagePaths.length > 0 && feature) { + const featurePath = path.join( + projectPath, + ".automaker", + "features", + featureId, + "feature.json" + ); + + try { + await fs.writeFile(featurePath, JSON.stringify(feature, null, 2)); + } catch (error) { + console.error(`[AutoMode] Failed to save feature.json:`, error); + } + } + + await this.runAgent(workDir, featureId, followUpPrompt, abortController, allImagePaths.length > 0 ? allImagePaths : undefined, model); + + // Mark as waiting_approval for user review + await this.updateFeatureStatus(projectPath, featureId, "waiting_approval"); this.emitAutoModeEvent("auto_mode_feature_complete", { featureId, @@ -544,23 +671,25 @@ export class AutoModeService { Format your response as a structured markdown document.`; try { - const options: Options = { + const provider = ProviderFactory.getProviderForModel("claude-sonnet-4-20250514"); + + const options: ExecuteOptions = { + prompt, model: "claude-sonnet-4-20250514", maxTurns: 5, cwd: projectPath, allowedTools: ["Read", "Glob", "Grep"], - permissionMode: "acceptEdits", abortController, }; - const stream = query({ prompt, options }); + const stream = provider.executeQuery(options); let analysisResult = ""; for await (const msg of stream) { - if (msg.type === "assistant" && msg.message.content) { + if (msg.type === "assistant" && msg.message?.content) { for (const block of msg.message.content) { if (block.type === "text") { - analysisResult = block.text; + analysisResult = block.text || ""; this.emitAutoModeEvent("auto_mode_progress", { featureId: analysisFeatureId, content: block.text, @@ -736,6 +865,27 @@ ${feature.spec} `; } + // Add images note (like old implementation) + if (feature.imagePaths && feature.imagePaths.length > 0) { + const imagesList = feature.imagePaths + .map((img, idx) => { + const path = typeof img === "string" ? img : img.path; + const filename = typeof img === "string" ? path.split("/").pop() : img.filename || path.split("/").pop(); + const mimeType = typeof img === "string" ? "image/*" : img.mimeType || "image/*"; + return ` ${idx + 1}. ${filename} (${mimeType})\n Path: ${path}`; + }) + .join("\n"); + + prompt += ` +**📎 Context Images Attached:** +The user has attached ${feature.imagePaths.length} image(s) for context. These images are provided both visually (in the initial message) and as files you can read: + +${imagesList} + +You can use the Read tool to view these images at any time during implementation. Review them carefully before implementing. +`; + } + prompt += ` ## Instructions @@ -761,17 +911,62 @@ When done, summarize what you implemented and any notes for the developer.`; ): Promise { const finalModel = model || MODEL_MAP.opus; console.log(`[AutoMode] runAgent called for feature ${featureId} with model: ${finalModel}`); - - // Check if this is an OpenAI/Codex model - Claude Agent SDK doesn't support these - if (finalModel.startsWith("gpt-") || finalModel.startsWith("o")) { - const errorMessage = `OpenAI/Codex models (like "${finalModel}") are not yet supported in server mode. ` + - `Please use a Claude model (opus, sonnet, or haiku) instead. ` + - `OpenAI/Codex models are only supported in the Electron app.`; - console.error(`[AutoMode] ${errorMessage}`); - throw new Error(errorMessage); + + // Get provider for this model + const provider = ProviderFactory.getProviderForModel(finalModel); + + console.log( + `[AutoMode] Using provider "${provider.getName()}" for model "${finalModel}"` + ); + + // Build prompt content with images (like AgentService) + let promptContent: string | Array<{ type: string; text?: string; source?: object }> = prompt; + + if (imagePaths && imagePaths.length > 0) { + const contentBlocks: Array<{ type: string; text?: string; source?: object }> = []; + + // Add text block first + contentBlocks.push({ type: "text", text: prompt }); + + // Add image blocks (for vision models) + for (const imagePath of imagePaths) { + try { + // Make path absolute by prepending workDir if it's relative + const absolutePath = path.isAbsolute(imagePath) + ? imagePath + : path.join(workDir, imagePath); + + const imageBuffer = await fs.readFile(absolutePath); + const base64Data = imageBuffer.toString("base64"); + const ext = path.extname(imagePath).toLowerCase(); + const mimeTypeMap: Record = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", + }; + const mediaType = mimeTypeMap[ext] || "image/png"; + + contentBlocks.push({ + type: "image", + source: { + type: "base64", + media_type: mediaType, + data: base64Data, + }, + }); + + } catch (error) { + console.error(`[AutoMode] Failed to load image ${imagePath}:`, error); + } + } + + promptContent = contentBlocks; } - - const options: Options = { + + const options: ExecuteOptions = { + prompt: promptContent, model: finalModel, maxTurns: 50, cwd: workDir, @@ -783,35 +978,24 @@ When done, summarize what you implemented and any notes for the developer.`; "Grep", "Bash", ], - permissionMode: "acceptEdits", - sandbox: { - enabled: true, - autoAllowBashIfSandboxed: true, - }, abortController, }; - // Build prompt - include image paths for the agent to read - let finalPrompt = prompt; - - if (imagePaths && imagePaths.length > 0) { - finalPrompt = `${prompt}\n\n## Reference Images\nThe following images are available for reference. Use the Read tool to view them:\n${imagePaths.map((p) => `- ${p}`).join("\n")}`; - } - - const stream = query({ prompt: finalPrompt, options }); + // Execute via provider + const stream = provider.executeQuery(options); let responseText = ""; const outputPath = path.join(workDir, ".automaker", "features", featureId, "agent-output.md"); for await (const msg of stream) { - if (msg.type === "assistant" && msg.message.content) { + if (msg.type === "assistant" && msg.message?.content) { for (const block of msg.message.content) { if (block.type === "text") { - responseText = block.text; + responseText = block.text || ""; // Check for authentication errors in the response - if (block.text.includes("Invalid API key") || + if (block.text && (block.text.includes("Invalid API key") || block.text.includes("authentication_failed") || - block.text.includes("Fix external API key")) { + block.text.includes("Fix external API key"))) { throw new Error( "Authentication failed: Invalid or expired API key. " + "Please check your ANTHROPIC_API_KEY or run 'claude login' to re-authenticate." @@ -830,20 +1014,10 @@ When done, summarize what you implemented and any notes for the developer.`; }); } } - } else if (msg.type === "assistant" && (msg as { error?: string }).error === "authentication_failed") { - // Handle authentication error from the SDK - throw new Error( - "Authentication failed: Invalid or expired API key. " + - "Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate." - ); + } else if (msg.type === "error") { + // Handle error messages + throw new Error(msg.error || "Unknown error"); } else if (msg.type === "result" && msg.subtype === "success") { - // Check if result indicates an error - if (msg.is_error && msg.result?.includes("Invalid API key")) { - throw new Error( - "Authentication failed: Invalid or expired API key. " + - "Please set a valid ANTHROPIC_API_KEY environment variable or run 'claude login' to authenticate." - ); - } responseText = msg.result || responseText; } }