diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index 60db38c1..615d0db7 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -32,6 +32,7 @@ import { supportsReasoningEffort, type CodexApprovalPolicy, type CodexSandboxMode, + type CodexAuthStatus, } from '@automaker/types'; import { CodexConfigManager } from './codex-config-manager.js'; import { executeCodexSdkQuery } from './codex-sdk-client.js'; @@ -56,6 +57,7 @@ const CODEX_OUTPUT_SCHEMA_FLAG = '--output-schema'; const CODEX_CONFIG_FLAG = '--config'; const CODEX_IMAGE_FLAG = '--image'; const CODEX_ADD_DIR_FLAG = '--add-dir'; +const CODEX_SKIP_GIT_REPO_CHECK_FLAG = '--skip-git-repo-check'; const CODEX_RESUME_FLAG = 'resume'; const CODEX_REASONING_EFFORT_KEY = 'reasoning_effort'; const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY'; @@ -742,7 +744,7 @@ export class CodexProvider extends BaseProvider { } const configOverrides = buildConfigOverrides(overrides); - const globalArgs = [CODEX_APPROVAL_FLAG, approvalPolicy]; + const globalArgs = [CODEX_SKIP_GIT_REPO_CHECK_FLAG, CODEX_APPROVAL_FLAG, approvalPolicy]; if (searchEnabled) { globalArgs.push(CODEX_SEARCH_FLAG); } @@ -782,6 +784,12 @@ export class CodexProvider extends BaseProvider { const event = rawEvent as Record; const eventType = getEventType(event); + // Track thread/session ID from events + const threadId = event.thread_id; + if (threadId && typeof threadId === 'string') { + this._lastSessionId = threadId; + } + if (eventType === CODEX_EVENT_TYPES.error) { const errorText = extractText(event.error ?? event.message) || 'Codex CLI error'; @@ -985,4 +993,121 @@ export class CodexProvider extends BaseProvider { // Return all available Codex/OpenAI models return CODEX_MODELS; } + + /** + * Check authentication status for Codex CLI + */ + async checkAuth(): Promise { + const cliPath = await findCodexCliPath(); + const hasApiKey = !!process.env[OPENAI_API_KEY_ENV]; + const authIndicators = await getCodexAuthIndicators(); + + // Check for API key in environment + if (hasApiKey) { + return { authenticated: true, method: 'api_key' }; + } + + // Check for OAuth/token from Codex CLI + if (authIndicators.hasOAuthToken || authIndicators.hasApiKey) { + return { authenticated: true, method: 'oauth' }; + } + + // CLI is installed but not authenticated + if (cliPath) { + try { + const result = await spawnProcess({ + command: cliPath || CODEX_COMMAND, + args: ['auth', 'status', '--json'], + cwd: process.cwd(), + }); + // If auth command succeeds, we're authenticated + if (result.exitCode === 0) { + return { authenticated: true, method: 'oauth' }; + } + } catch { + // Auth command failed, not authenticated + } + } + + return { authenticated: false, method: 'none' }; + } + + /** + * Deduplicate text blocks in Codex assistant messages + * + * Codex can send: + * 1. Duplicate consecutive text blocks (same text twice in a row) + * 2. A final accumulated block containing ALL previous text + * + * This method filters out these duplicates to prevent UI stuttering. + */ + private deduplicateTextBlocks( + content: Array<{ type: string; text?: string }>, + lastTextBlock: string, + accumulatedText: string + ): { content: Array<{ type: string; text?: string }>; lastBlock: string; accumulated: string } { + const filtered: Array<{ type: string; text?: string }> = []; + let newLastBlock = lastTextBlock; + let newAccumulated = accumulatedText; + + for (const block of content) { + if (block.type !== 'text' || !block.text) { + filtered.push(block); + continue; + } + + const text = block.text; + + // Skip empty text + if (!text.trim()) continue; + + // Skip duplicate consecutive text blocks + if (text === newLastBlock) { + continue; + } + + // Skip final accumulated text block + // Codex sends one large block containing ALL previous text at the end + if (newAccumulated.length > 100 && text.length > newAccumulated.length * 0.8) { + const normalizedAccum = newAccumulated.replace(/\s+/g, ' ').trim(); + const normalizedNew = text.replace(/\s+/g, ' ').trim(); + if (normalizedNew.includes(normalizedAccum.slice(0, 100))) { + // This is the final accumulated block, skip it + continue; + } + } + + // This is a valid new text block + newLastBlock = text; + newAccumulated += text; + filtered.push(block); + } + + return { content: filtered, lastBlock: newLastBlock, accumulated: newAccumulated }; + } + + /** + * Get the detected CLI path (public accessor for status endpoints) + */ + async getCliPath(): Promise { + const path = await findCodexCliPath(); + return path || null; + } + + /** + * Get the last CLI session ID (for tracking across queries) + * This can be used to resume sessions in subsequent requests + */ + getLastSessionId(): string | null { + return this._lastSessionId ?? null; + } + + /** + * Set a session ID to use for CLI session resumption + */ + setSessionId(sessionId: string | null): void { + this._lastSessionId = sessionId; + } + + private _lastSessionId: string | null = null; } diff --git a/apps/server/src/providers/codex-tool-mapping.ts b/apps/server/src/providers/codex-tool-mapping.ts index 2f9059a0..f951e0f0 100644 --- a/apps/server/src/providers/codex-tool-mapping.ts +++ b/apps/server/src/providers/codex-tool-mapping.ts @@ -16,6 +16,8 @@ const TOOL_NAME_WRITE = 'Write'; const TOOL_NAME_GREP = 'Grep'; const TOOL_NAME_GLOB = 'Glob'; const TOOL_NAME_TODO = 'TodoWrite'; +const TOOL_NAME_DELETE = 'Delete'; +const TOOL_NAME_LS = 'Ls'; const INPUT_KEY_COMMAND = 'command'; const INPUT_KEY_FILE_PATH = 'file_path'; @@ -37,6 +39,8 @@ const WRAPPER_COMMANDS = new Set(['sudo', 'env', 'command']); const READ_COMMANDS = new Set(['cat', 'sed', 'head', 'tail', 'less', 'more', 'bat', 'stat', 'wc']); const SEARCH_COMMANDS = new Set(['rg', 'grep', 'ag', 'ack']); const GLOB_COMMANDS = new Set(['ls', 'find', 'fd', 'tree']); +const DELETE_COMMANDS = new Set(['rm', 'del', 'erase', 'remove', 'unlink']); +const LIST_COMMANDS = new Set(['ls', 'dir', 'll', 'la']); const WRITE_COMMANDS = new Set(['tee', 'touch', 'mkdir']); const APPLY_PATCH_COMMAND = 'apply_patch'; const APPLY_PATCH_PATTERN = /\bapply_patch\b/; @@ -193,6 +197,18 @@ function extractRedirectionTarget(command: string): string | null { return match?.[1] ?? null; } +function extractFilePathFromDeleteTokens(tokens: string[]): string | null { + // rm file.txt or rm /path/to/file.txt + // Skip flags and get the first non-flag argument + for (let i = 1; i < tokens.length; i++) { + const token = tokens[i]; + if (token && !token.startsWith('-')) { + return token; + } + } + return null; +} + function hasSedInPlaceFlag(tokens: string[]): boolean { return tokens.some((token) => SED_IN_PLACE_FLAGS.has(token) || token.startsWith('-i')); } @@ -279,6 +295,41 @@ export function resolveCodexToolCall(command: string): CodexToolResolution { }; } + // Handle Delete commands (rm, del, erase, remove, unlink) + if (DELETE_COMMANDS.has(commandToken)) { + // Skip if -r or -rf flags (recursive delete should go to Bash) + if ( + tokens.some((token) => token === '-r' || token === '-rf' || token === '-f' || token === '-rf') + ) { + return { + name: TOOL_NAME_BASH, + input: { [INPUT_KEY_COMMAND]: normalized }, + }; + } + // Simple file deletion - extract the file path + const filePath = extractFilePathFromDeleteTokens(tokens); + if (filePath) { + return { + name: TOOL_NAME_DELETE, + input: { path: filePath }, + }; + } + // Fall back to bash if we can't determine the file path + return { + name: TOOL_NAME_BASH, + input: { [INPUT_KEY_COMMAND]: normalized }, + }; + } + + // Handle simple Ls commands (just listing, not find/glob) + if (LIST_COMMANDS.has(commandToken)) { + const filePath = extractFilePathFromTokens(tokens); + return { + name: TOOL_NAME_LS, + input: { path: filePath || '.' }, + }; + } + if (GLOB_COMMANDS.has(commandToken)) { return { name: TOOL_NAME_GLOB, diff --git a/apps/server/src/providers/provider-factory.ts b/apps/server/src/providers/provider-factory.ts index 8e5cc509..0dde03ad 100644 --- a/apps/server/src/providers/provider-factory.ts +++ b/apps/server/src/providers/provider-factory.ts @@ -173,12 +173,21 @@ export class ProviderFactory { model.id === modelId || model.modelString === modelId || model.id.endsWith(`-${modelId}`) || - model.modelString === modelId.replace(/^(claude|cursor|codex)-/, '') + model.modelString.endsWith(`-${modelId}`) || + model.modelString === modelId.replace(/^(claude|cursor|codex)-/, '') || + model.modelString === modelId.replace(/-(claude|cursor|codex)$/, '') ) { return model.supportsVision ?? true; } } + // Also try exact match with model string from provider's model map + for (const model of models) { + if (model.modelString === modelId || model.id === modelId) { + return model.supportsVision ?? true; + } + } + // Default to true (Claude SDK supports vision by default) return true; } diff --git a/apps/server/src/routes/setup/routes/api-keys.ts b/apps/server/src/routes/setup/routes/api-keys.ts index d052c187..047b6455 100644 --- a/apps/server/src/routes/setup/routes/api-keys.ts +++ b/apps/server/src/routes/setup/routes/api-keys.ts @@ -11,6 +11,7 @@ export function createApiKeysHandler() { res.json({ success: true, hasAnthropicKey: !!getApiKey('anthropic') || !!process.env.ANTHROPIC_API_KEY, + hasOpenaiKey: !!getApiKey('openai') || !!process.env.OPENAI_API_KEY, }); } catch (error) { logError(error, 'Get API keys failed'); diff --git a/apps/server/src/routes/setup/routes/delete-api-key.ts b/apps/server/src/routes/setup/routes/delete-api-key.ts index 0fee1b8b..242425fb 100644 --- a/apps/server/src/routes/setup/routes/delete-api-key.ts +++ b/apps/server/src/routes/setup/routes/delete-api-key.ts @@ -46,13 +46,14 @@ export function createDeleteApiKeyHandler() { // Map provider to env key name const envKeyMap: Record = { anthropic: 'ANTHROPIC_API_KEY', + openai: 'OPENAI_API_KEY', }; const envKey = envKeyMap[provider]; if (!envKey) { res.status(400).json({ success: false, - error: `Unknown provider: ${provider}. Only anthropic is supported.`, + error: `Unknown provider: ${provider}. Only anthropic and openai are supported.`, }); return; } diff --git a/apps/server/src/routes/setup/routes/verify-codex-auth.ts b/apps/server/src/routes/setup/routes/verify-codex-auth.ts index ba0df833..00edd0f3 100644 --- a/apps/server/src/routes/setup/routes/verify-codex-auth.ts +++ b/apps/server/src/routes/setup/routes/verify-codex-auth.ts @@ -82,7 +82,10 @@ function isRateLimitError(text: string): boolean { export function createVerifyCodexAuthHandler() { return async (req: Request, res: Response): Promise => { - const { authMethod } = req.body as { authMethod?: 'cli' | 'api_key' }; + const { authMethod, apiKey } = req.body as { + authMethod?: 'cli' | 'api_key'; + apiKey?: string; + }; // Create session ID for cleanup const sessionId = `codex-auth-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; @@ -105,21 +108,32 @@ export function createVerifyCodexAuthHandler() { try { // Create secure environment without modifying process.env - const authEnv = createSecureAuthEnv(authMethod || 'api_key', undefined, 'openai'); + const authEnv = createSecureAuthEnv(authMethod || 'api_key', apiKey, 'openai'); - // For API key auth, use stored key + // For API key auth, validate and use the provided key or stored key if (authMethod === 'api_key') { - const storedApiKey = getApiKey('openai'); - if (storedApiKey) { - const validation = validateApiKey(storedApiKey, 'openai'); + if (apiKey) { + // Use the provided API key + const validation = validateApiKey(apiKey, 'openai'); if (!validation.isValid) { res.json({ success: true, authenticated: false, error: validation.error }); return; } authEnv[OPENAI_API_KEY_ENV] = validation.normalizedKey; - } else if (!authEnv[OPENAI_API_KEY_ENV]) { - res.json({ success: true, authenticated: false, error: ERROR_API_KEY_REQUIRED }); - return; + } else { + // Try stored key + const storedApiKey = getApiKey('openai'); + if (storedApiKey) { + const validation = validateApiKey(storedApiKey, 'openai'); + if (!validation.isValid) { + res.json({ success: true, authenticated: false, error: validation.error }); + return; + } + authEnv[OPENAI_API_KEY_ENV] = validation.normalizedKey; + } else if (!authEnv[OPENAI_API_KEY_ENV]) { + res.json({ success: true, authenticated: false, error: ERROR_API_KEY_REQUIRED }); + return; + } } } diff --git a/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx b/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx index e0261e97..f4289a4d 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx +++ b/apps/ui/src/components/views/settings-view/api-keys/api-keys-section.tsx @@ -14,8 +14,15 @@ import { useNavigate } from '@tanstack/react-router'; export function ApiKeysSection() { const { apiKeys, setApiKeys } = useAppStore(); - const { claudeAuthStatus, setClaudeAuthStatus, setSetupComplete } = useSetupStore(); + const { + claudeAuthStatus, + setClaudeAuthStatus, + codexAuthStatus, + setCodexAuthStatus, + setSetupComplete, + } = useSetupStore(); const [isDeletingAnthropicKey, setIsDeletingAnthropicKey] = useState(false); + const [isDeletingOpenaiKey, setIsDeletingOpenaiKey] = useState(false); const navigate = useNavigate(); const { providerConfigParams, handleSave, saved } = useApiKeyManagement(); @@ -51,6 +58,34 @@ export function ApiKeysSection() { } }, [apiKeys, setApiKeys, claudeAuthStatus, setClaudeAuthStatus]); + // Delete OpenAI API key + const deleteOpenaiKey = useCallback(async () => { + setIsDeletingOpenaiKey(true); + try { + const api = getElectronAPI(); + if (!api.setup?.deleteApiKey) { + toast.error('Delete API not available'); + return; + } + + const result = await api.setup.deleteApiKey('openai'); + if (result.success) { + setApiKeys({ ...apiKeys, openai: '' }); + setCodexAuthStatus({ + authenticated: false, + method: 'none', + }); + toast.success('OpenAI API key deleted'); + } else { + toast.error(result.error || 'Failed to delete API key'); + } + } catch (error) { + toast.error('Failed to delete API key'); + } finally { + setIsDeletingOpenaiKey(false); + } + }, [apiKeys, setApiKeys, setCodexAuthStatus]); + // Open setup wizard const openSetupWizard = useCallback(() => { setSetupComplete(false); @@ -137,6 +172,23 @@ export function ApiKeysSection() { Delete Anthropic Key )} + + {apiKeys.openai && ( + + )} diff --git a/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts b/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts index d5f2db51..6cff2f83 100644 --- a/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts +++ b/apps/ui/src/components/views/settings-view/api-keys/hooks/use-api-key-management.ts @@ -15,6 +15,7 @@ interface TestResult { interface ApiKeyStatus { hasAnthropicKey: boolean; hasGoogleKey: boolean; + hasOpenaiKey: boolean; } /** @@ -27,16 +28,20 @@ export function useApiKeyManagement() { // API key values const [anthropicKey, setAnthropicKey] = useState(apiKeys.anthropic); const [googleKey, setGoogleKey] = useState(apiKeys.google); + const [openaiKey, setOpenaiKey] = useState(apiKeys.openai); // Visibility toggles const [showAnthropicKey, setShowAnthropicKey] = useState(false); const [showGoogleKey, setShowGoogleKey] = useState(false); + const [showOpenaiKey, setShowOpenaiKey] = useState(false); // Test connection states const [testingConnection, setTestingConnection] = useState(false); const [testResult, setTestResult] = useState(null); const [testingGeminiConnection, setTestingGeminiConnection] = useState(false); const [geminiTestResult, setGeminiTestResult] = useState(null); + const [testingOpenaiConnection, setTestingOpenaiConnection] = useState(false); + const [openaiTestResult, setOpenaiTestResult] = useState(null); // API key status from environment const [apiKeyStatus, setApiKeyStatus] = useState(null); @@ -48,6 +53,7 @@ export function useApiKeyManagement() { useEffect(() => { setAnthropicKey(apiKeys.anthropic); setGoogleKey(apiKeys.google); + setOpenaiKey(apiKeys.openai); }, [apiKeys]); // Check API key status from environment on mount @@ -61,6 +67,7 @@ export function useApiKeyManagement() { setApiKeyStatus({ hasAnthropicKey: status.hasAnthropicKey, hasGoogleKey: status.hasGoogleKey, + hasOpenaiKey: status.hasOpenaiKey, }); } } catch (error) { @@ -136,11 +143,42 @@ export function useApiKeyManagement() { setTestingGeminiConnection(false); }; + // Test OpenAI/Codex connection + const handleTestOpenaiConnection = async () => { + setTestingOpenaiConnection(true); + setOpenaiTestResult(null); + + try { + const api = getElectronAPI(); + const data = await api.setup.verifyCodexAuth('api_key', openaiKey); + + if (data.success && data.authenticated) { + setOpenaiTestResult({ + success: true, + message: 'Connection successful! Codex responded.', + }); + } else { + setOpenaiTestResult({ + success: false, + message: data.error || 'Failed to connect to OpenAI API.', + }); + } + } catch { + setOpenaiTestResult({ + success: false, + message: 'Network error. Please check your connection.', + }); + } finally { + setTestingOpenaiConnection(false); + } + }; + // Save API keys const handleSave = () => { setApiKeys({ anthropic: anthropicKey, google: googleKey, + openai: openaiKey, }); setSaved(true); setTimeout(() => setSaved(false), 2000); @@ -167,6 +205,15 @@ export function useApiKeyManagement() { onTest: handleTestGeminiConnection, result: geminiTestResult, }, + openai: { + value: openaiKey, + setValue: setOpenaiKey, + show: showOpenaiKey, + setShow: setShowOpenaiKey, + testing: testingOpenaiConnection, + onTest: handleTestOpenaiConnection, + result: openaiTestResult, + }, }; return { diff --git a/apps/ui/src/components/views/settings-view/providers/codex-model-configuration.tsx b/apps/ui/src/components/views/settings-view/providers/codex-model-configuration.tsx new file mode 100644 index 00000000..e3849f26 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/codex-model-configuration.tsx @@ -0,0 +1,183 @@ +import { Label } from '@/components/ui/label'; +import { Badge } from '@/components/ui/badge'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Cpu } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { CodexModelId } from '@automaker/types'; +import { CODEX_MODEL_MAP } from '@automaker/types'; +import { OpenAIIcon } from '@/components/ui/provider-icon'; + +interface CodexModelConfigurationProps { + enabledCodexModels: CodexModelId[]; + codexDefaultModel: CodexModelId; + isSaving: boolean; + onDefaultModelChange: (model: CodexModelId) => void; + onModelToggle: (model: CodexModelId, enabled: boolean) => void; +} + +interface CodexModelInfo { + id: CodexModelId; + label: string; + description: string; +} + +const CODEX_MODEL_INFO: Record = { + 'gpt-5.2-codex': { + id: 'gpt-5.2-codex', + label: 'GPT-5.2-Codex', + description: 'Most advanced agentic coding model for complex software engineering', + }, + 'gpt-5-codex': { + id: 'gpt-5-codex', + label: 'GPT-5-Codex', + description: 'Purpose-built for Codex CLI with versatile tool use', + }, + 'gpt-5-codex-mini': { + id: 'gpt-5-codex-mini', + label: 'GPT-5-Codex-Mini', + description: 'Faster workflows optimized for low-latency code Q&A and editing', + }, + 'codex-1': { + id: 'codex-1', + label: 'Codex-1', + description: 'Version of o3 optimized for software engineering', + }, + 'codex-mini-latest': { + id: 'codex-mini-latest', + label: 'Codex-Mini-Latest', + description: 'Version of o4-mini for Codex, optimized for faster workflows', + }, + 'gpt-5': { + id: 'gpt-5', + label: 'GPT-5', + description: 'GPT-5 base flagship model', + }, +}; + +export function CodexModelConfiguration({ + enabledCodexModels, + codexDefaultModel, + isSaving, + onDefaultModelChange, + onModelToggle, +}: CodexModelConfigurationProps) { + const availableModels = Object.values(CODEX_MODEL_INFO); + + return ( +
+
+
+
+ +
+

+ Model Configuration +

+
+

+ Configure which Codex models are available in the feature modal +

+
+
+
+ + +
+ +
+ +
+ {availableModels.map((model) => { + const isEnabled = enabledCodexModels.includes(model.id); + const isDefault = model.id === codexDefaultModel; + + return ( +
+
+ onModelToggle(model.id, !!checked)} + disabled={isSaving || isDefault} + /> +
+
+ {model.label} + {supportsReasoningEffort(model.id) && ( + + Thinking + + )} + {isDefault && ( + + Default + + )} +
+

{model.description}

+
+
+
+ ); + })} +
+
+
+
+ ); +} + +function getModelDisplayName(modelId: string): string { + const displayNames: Record = { + 'gpt-5.2-codex': 'GPT-5.2-Codex', + 'gpt-5-codex': 'GPT-5-Codex', + 'gpt-5-codex-mini': 'GPT-5-Codex-Mini', + 'codex-1': 'Codex-1', + 'codex-mini-latest': 'Codex-Mini-Latest', + 'gpt-5': 'GPT-5', + }; + return displayNames[modelId] || modelId; +} + +function supportsReasoningEffort(modelId: string): boolean { + const reasoningModels = ['gpt-5.2-codex', 'gpt-5-codex', 'gpt-5', 'codex-1']; + return reasoningModels.includes(modelId); +} diff --git a/apps/ui/src/components/views/settings-view/providers/codex-settings-tab.tsx b/apps/ui/src/components/views/settings-view/providers/codex-settings-tab.tsx index 7ceb45e0..0f8efdc1 100644 --- a/apps/ui/src/components/views/settings-view/providers/codex-settings-tab.tsx +++ b/apps/ui/src/components/views/settings-view/providers/codex-settings-tab.tsx @@ -1,27 +1,35 @@ -import { useState, useCallback } from 'react'; +import { useState, useCallback, useEffect } from 'react'; import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { CodexCliStatus } from '../cli-status/codex-cli-status'; import { CodexSettings } from '../codex/codex-settings'; import { CodexUsageSection } from '../codex/codex-usage-section'; -import { Info } from 'lucide-react'; +import { CodexModelConfiguration } from './codex-model-configuration'; import { getElectronAPI } from '@/lib/electron'; import { createLogger } from '@automaker/utils/logger'; import type { CliStatus as SharedCliStatus } from '../shared/types'; +import type { CodexModelId } from '@automaker/types'; const logger = createLogger('CodexSettings'); export function CodexSettingsTab() { - // TODO: Add these to app-store - const [codexAutoLoadAgents, setCodexAutoLoadAgents] = useState(false); - const [codexSandboxMode, setCodexSandboxMode] = useState< - 'read-only' | 'workspace-write' | 'danger-full-access' - >('read-only'); - const [codexApprovalPolicy, setCodexApprovalPolicy] = useState< - 'untrusted' | 'on-failure' | 'on-request' | 'never' - >('untrusted'); - const [codexEnableWebSearch, setCodexEnableWebSearch] = useState(false); - const [codexEnableImages, setCodexEnableImages] = useState(false); + const { + codexAutoLoadAgents, + codexSandboxMode, + codexApprovalPolicy, + codexEnableWebSearch, + codexEnableImages, + enabledCodexModels, + codexDefaultModel, + setCodexAutoLoadAgents, + setCodexSandboxMode, + setCodexApprovalPolicy, + setCodexEnableWebSearch, + setCodexEnableImages, + setEnabledCodexModels, + setCodexDefaultModel, + toggleCodexModel, + } = useAppStore(); const { codexAuthStatus, @@ -32,8 +40,8 @@ export function CodexSettingsTab() { const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false); const [displayCliStatus, setDisplayCliStatus] = useState(null); + const [isSaving, setIsSaving] = useState(false); - // Convert setup-store CliStatus to shared/types CliStatus for display const codexCliStatus: SharedCliStatus | null = displayCliStatus || (setupCliStatus @@ -46,28 +54,28 @@ export function CodexSettingsTab() { } : null); - const handleRefreshCodexCli = useCallback(async () => { - setIsCheckingCodexCli(true); - try { + // Load Codex CLI status on mount + useEffect(() => { + const checkCodexStatus = async () => { const api = getElectronAPI(); if (api?.setup?.getCodexStatus) { - const result = await api.setup.getCodexStatus(); - if (result.success) { - // Update setup store + try { + const result = await api.setup.getCodexStatus(); + setDisplayCliStatus({ + success: result.success, + status: result.installed ? 'installed' : 'not_installed', + method: result.auth?.method, + version: result.version, + path: result.path, + recommendation: result.recommendation, + installCommands: result.installCommands, + }); setCodexCliStatus({ installed: result.installed, version: result.version, path: result.path, method: result.auth?.method || 'none', }); - // Update display status - setDisplayCliStatus({ - success: true, - status: result.installed ? 'installed' : 'not_installed', - method: result.auth?.method, - version: result.version || undefined, - path: result.path || undefined, - }); if (result.auth) { setCodexAuthStatus({ authenticated: result.auth.authenticated, @@ -80,6 +88,42 @@ export function CodexSettingsTab() { hasApiKey: result.auth.hasApiKey, }); } + } catch (error) { + logger.error('Failed to check Codex CLI status:', error); + } + } + }; + checkCodexStatus(); + }, [setCodexCliStatus, setCodexAuthStatus]); + + const handleRefreshCodexCli = useCallback(async () => { + setIsCheckingCodexCli(true); + try { + const api = getElectronAPI(); + if (api?.setup?.getCodexStatus) { + const result = await api.setup.getCodexStatus(); + setDisplayCliStatus({ + success: result.success, + status: result.installed ? 'installed' : 'not_installed', + method: result.auth?.method, + version: result.version, + path: result.path, + recommendation: result.recommendation, + installCommands: result.installCommands, + }); + setCodexCliStatus({ + installed: result.installed, + version: result.version, + path: result.path, + method: result.auth?.method || 'none', + }); + if (result.auth) { + setCodexAuthStatus({ + authenticated: result.auth.authenticated, + method: result.auth.method as 'cli_authenticated' | 'api_key' | 'api_key_env' | 'none', + hasAuthFile: result.auth.method === 'cli_authenticated', + hasApiKey: result.auth.hasApiKey, + }); } } } catch (error) { @@ -89,27 +133,50 @@ export function CodexSettingsTab() { } }, [setCodexCliStatus, setCodexAuthStatus]); - // Show usage tracking when CLI is authenticated + const handleDefaultModelChange = useCallback( + (model: CodexModelId) => { + setIsSaving(true); + try { + setCodexDefaultModel(model); + } finally { + setIsSaving(false); + } + }, + [setCodexDefaultModel] + ); + + const handleModelToggle = useCallback( + (model: CodexModelId, enabled: boolean) => { + setIsSaving(true); + try { + toggleCodexModel(model, enabled); + } finally { + setIsSaving(false); + } + }, + [toggleCodexModel] + ); + const showUsageTracking = codexAuthStatus?.authenticated ?? false; return (
- {/* Usage Info */} -
- -
- OpenAI via Codex CLI -

- Access GPT models with tool support for advanced coding workflows. -

-
-
- + + {showUsageTracking && } + + + - {showUsageTracking && }
); } diff --git a/apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx index 9e08390d..cf581f8c 100644 --- a/apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx @@ -75,7 +75,10 @@ interface CliSetupConfig { buildClearedAuthStatus: (previous: CliSetupAuthStatus | null) => CliSetupAuthStatus; statusApi: () => Promise; installApi: () => Promise; - verifyAuthApi: (method: 'cli' | 'api_key') => Promise<{ + verifyAuthApi: ( + method: 'cli' | 'api_key', + apiKey?: string + ) => Promise<{ success: boolean; authenticated: boolean; error?: string; @@ -194,7 +197,7 @@ export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetup setApiKeyVerificationError(null); try { - const result = await config.verifyAuthApi('api_key'); + const result = await config.verifyAuthApi('api_key', apiKey); const hasLimitOrBillingError = result.error?.toLowerCase().includes('limit reached') || diff --git a/apps/ui/src/components/views/setup-view/steps/codex-setup-step.tsx b/apps/ui/src/components/views/setup-view/steps/codex-setup-step.tsx index 359d2278..438ed57f 100644 --- a/apps/ui/src/components/views/setup-view/steps/codex-setup-step.tsx +++ b/apps/ui/src/components/views/setup-view/steps/codex-setup-step.tsx @@ -31,8 +31,8 @@ export function CodexSetupStep({ onNext, onBack, onSkip }: CodexSetupStepProps) ); const verifyAuthApi = useCallback( - (method: 'cli' | 'api_key') => - getElectronAPI().setup?.verifyCodexAuth(method) || Promise.reject(), + (method: 'cli' | 'api_key', apiKey?: string) => + getElectronAPI().setup?.verifyCodexAuth(method, apiKey) || Promise.reject(), [] ); diff --git a/apps/ui/src/config/api-providers.ts b/apps/ui/src/config/api-providers.ts index 6c7742e7..e3cc2a51 100644 --- a/apps/ui/src/config/api-providers.ts +++ b/apps/ui/src/config/api-providers.ts @@ -50,11 +50,21 @@ export interface ProviderConfigParams { onTest: () => Promise; result: { success: boolean; message: string } | null; }; + openai: { + value: string; + setValue: Dispatch>; + show: boolean; + setShow: Dispatch>; + testing: boolean; + onTest: () => Promise; + result: { success: boolean; message: string } | null; + }; } export const buildProviderConfigs = ({ apiKeys, anthropic, + openai, }: ProviderConfigParams): ProviderConfig[] => [ { key: 'anthropic', @@ -82,6 +92,32 @@ export const buildProviderConfigs = ({ descriptionLinkText: 'console.anthropic.com', descriptionSuffix: '.', }, + { + key: 'openai', + label: 'OpenAI API Key', + inputId: 'openai-key', + placeholder: 'sk-...', + value: openai.value, + setValue: openai.setValue, + showValue: openai.show, + setShowValue: openai.setShow, + hasStoredKey: apiKeys.openai, + inputTestId: 'openai-api-key-input', + toggleTestId: 'toggle-openai-visibility', + testButton: { + onClick: openai.onTest, + disabled: !openai.value || openai.testing, + loading: openai.testing, + testId: 'test-openai-connection', + }, + result: openai.result, + resultTestId: 'openai-test-connection-result', + resultMessageTestId: 'openai-test-connection-message', + descriptionPrefix: 'Used for Codex and OpenAI features. Get your key at', + descriptionLinkHref: 'https://platform.openai.com/api-keys', + descriptionLinkText: 'platform.openai.com', + descriptionSuffix: '.', + }, // { // key: "google", // label: "Google API Key (Gemini)", diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index b48e80fd..d1e51992 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1220,12 +1220,13 @@ export class HttpApiClient implements ElectronAPI { }> => this.post('/api/setup/auth-codex'), verifyCodexAuth: ( - authMethod?: 'cli' | 'api_key' + authMethod: 'cli' | 'api_key', + apiKey?: string ): Promise<{ success: boolean; authenticated: boolean; error?: string; - }> => this.post('/api/setup/verify-codex-auth', { authMethod }), + }> => this.post('/api/setup/verify-codex-auth', { authMethod, apiKey }), onInstallProgress: (callback: (progress: unknown) => void) => { return this.subscribeToEvent('agent:stream', callback); diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index 2ecb6ac0..960348c0 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -11,6 +11,7 @@ import type { ModelProvider, AIProfile, CursorModelId, + CodexModelId, PhaseModelConfig, PhaseModelKey, PhaseModelEntry, @@ -20,7 +21,7 @@ import type { PipelineStep, PromptCustomization, } from '@automaker/types'; -import { getAllCursorModelIds, DEFAULT_PHASE_MODELS } from '@automaker/types'; +import { getAllCursorModelIds, getAllCodexModelIds, DEFAULT_PHASE_MODELS } from '@automaker/types'; // Re-export types for convenience export type { @@ -515,6 +516,15 @@ export interface AppState { enabledCursorModels: CursorModelId[]; // Which Cursor models are available in feature modal cursorDefaultModel: CursorModelId; // Default Cursor model selection + // Codex CLI Settings (global) + enabledCodexModels: CodexModelId[]; // Which Codex models are available in feature modal + codexDefaultModel: CodexModelId; // Default Codex model selection + codexAutoLoadAgents: boolean; // Auto-load .codex/AGENTS.md files + codexSandboxMode: 'read-only' | 'workspace-write' | 'danger-full-access'; // Sandbox policy + codexApprovalPolicy: 'untrusted' | 'on-failure' | 'on-request' | 'never'; // Approval policy + codexEnableWebSearch: boolean; // Enable web search capability + codexEnableImages: boolean; // Enable image processing + // Claude Agent SDK Settings autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option enableSandboxMode: boolean; // Enable sandbox mode for bash commands (may cause issues on some systems) @@ -852,6 +862,20 @@ export interface AppActions { setCursorDefaultModel: (model: CursorModelId) => void; toggleCursorModel: (model: CursorModelId, enabled: boolean) => void; + // Codex CLI Settings actions + setEnabledCodexModels: (models: CodexModelId[]) => void; + setCodexDefaultModel: (model: CodexModelId) => void; + toggleCodexModel: (model: CodexModelId, enabled: boolean) => void; + setCodexAutoLoadAgents: (enabled: boolean) => Promise; + setCodexSandboxMode: ( + mode: 'read-only' | 'workspace-write' | 'danger-full-access' + ) => Promise; + setCodexApprovalPolicy: ( + policy: 'untrusted' | 'on-failure' | 'on-request' | 'never' + ) => Promise; + setCodexEnableWebSearch: (enabled: boolean) => Promise; + setCodexEnableImages: (enabled: boolean) => Promise; + // Claude Agent SDK Settings actions setAutoLoadClaudeMd: (enabled: boolean) => Promise; setEnableSandboxMode: (enabled: boolean) => Promise; @@ -1076,6 +1100,13 @@ const initialState: AppState = { favoriteModels: [], enabledCursorModels: getAllCursorModelIds(), // All Cursor models enabled by default cursorDefaultModel: 'auto', // Default to auto selection + enabledCodexModels: getAllCodexModelIds(), // All Codex models enabled by default + codexDefaultModel: 'gpt-5.2-codex', // Default to GPT-5.2-Codex + codexAutoLoadAgents: false, // Default to disabled (user must opt-in) + codexSandboxMode: 'workspace-write', // Default to workspace-write for safety + codexApprovalPolicy: 'on-request', // Default to on-request for balanced safety + codexEnableWebSearch: false, // Default to disabled + codexEnableImages: false, // Default to disabled autoLoadClaudeMd: false, // Default to disabled (user must opt-in) enableSandboxMode: false, // Default to disabled (can be enabled for additional security) skipSandboxWarning: false, // Default to disabled (show sandbox warning dialog) @@ -1761,6 +1792,41 @@ export const useAppStore = create()( : state.enabledCursorModels.filter((m) => m !== model), })), + // Codex CLI Settings actions + setEnabledCodexModels: (models) => set({ enabledCodexModels: models }), + setCodexDefaultModel: (model) => set({ codexDefaultModel: model }), + toggleCodexModel: (model, enabled) => + set((state) => ({ + enabledCodexModels: enabled + ? [...state.enabledCodexModels, model] + : state.enabledCodexModels.filter((m) => m !== model), + })), + setCodexAutoLoadAgents: async (enabled) => { + set({ codexAutoLoadAgents: enabled }); + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + setCodexSandboxMode: async (mode) => { + set({ codexSandboxMode: mode }); + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + setCodexApprovalPolicy: async (policy) => { + set({ codexApprovalPolicy: policy }); + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + setCodexEnableWebSearch: async (enabled) => { + set({ codexEnableWebSearch: enabled }); + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + setCodexEnableImages: async (enabled) => { + set({ codexEnableImages: enabled }); + const { syncSettingsToServer } = await import('@/hooks/use-settings-migration'); + await syncSettingsToServer(); + }, + // Claude Agent SDK Settings actions setAutoLoadClaudeMd: async (enabled) => { set({ autoLoadClaudeMd: enabled }); @@ -3073,6 +3139,13 @@ export const useAppStore = create()( phaseModels: state.phaseModels, enabledCursorModels: state.enabledCursorModels, cursorDefaultModel: state.cursorDefaultModel, + enabledCodexModels: state.enabledCodexModels, + codexDefaultModel: state.codexDefaultModel, + codexAutoLoadAgents: state.codexAutoLoadAgents, + codexSandboxMode: state.codexSandboxMode, + codexApprovalPolicy: state.codexApprovalPolicy, + codexEnableWebSearch: state.codexEnableWebSearch, + codexEnableImages: state.codexEnableImages, autoLoadClaudeMd: state.autoLoadClaudeMd, enableSandboxMode: state.enableSandboxMode, skipSandboxWarning: state.skipSandboxWarning, diff --git a/libs/types/src/codex-models.ts b/libs/types/src/codex-models.ts new file mode 100644 index 00000000..8914ffa5 --- /dev/null +++ b/libs/types/src/codex-models.ts @@ -0,0 +1,100 @@ +/** + * Codex CLI Model IDs + * Based on OpenAI Codex CLI official models + * Reference: https://developers.openai.com/codex/models/ + */ +export type CodexModelId = + | 'gpt-5.2-codex' // Most advanced agentic coding model for complex software engineering + | 'gpt-5-codex' // Purpose-built for Codex CLI with versatile tool use + | 'gpt-5-codex-mini' // Faster workflows optimized for low-latency code Q&A and editing + | 'codex-1' // Version of o3 optimized for software engineering + | 'codex-mini-latest' // Version of o4-mini for Codex, optimized for faster workflows + | 'gpt-5'; // GPT-5 base flagship model + +/** + * Codex model metadata + */ +export interface CodexModelConfig { + id: CodexModelId; + label: string; + description: string; + hasThinking: boolean; + /** Whether the model supports vision/image inputs */ + supportsVision: boolean; +} + +/** + * Complete model map for Codex CLI + */ +export const CODEX_MODEL_CONFIG_MAP: Record = { + 'gpt-5.2-codex': { + id: 'gpt-5.2-codex', + label: 'GPT-5.2-Codex', + description: 'Most advanced agentic coding model for complex software engineering', + hasThinking: true, + supportsVision: true, // GPT-5 supports vision + }, + 'gpt-5-codex': { + id: 'gpt-5-codex', + label: 'GPT-5-Codex', + description: 'Purpose-built for Codex CLI with versatile tool use', + hasThinking: true, + supportsVision: true, + }, + 'gpt-5-codex-mini': { + id: 'gpt-5-codex-mini', + label: 'GPT-5-Codex-Mini', + description: 'Faster workflows optimized for low-latency code Q&A and editing', + hasThinking: false, + supportsVision: true, + }, + 'codex-1': { + id: 'codex-1', + label: 'Codex-1', + description: 'Version of o3 optimized for software engineering', + hasThinking: true, + supportsVision: true, + }, + 'codex-mini-latest': { + id: 'codex-mini-latest', + label: 'Codex-Mini-Latest', + description: 'Version of o4-mini for Codex, optimized for faster workflows', + hasThinking: false, + supportsVision: true, + }, + 'gpt-5': { + id: 'gpt-5', + label: 'GPT-5', + description: 'GPT-5 base flagship model', + hasThinking: true, + supportsVision: true, + }, +}; + +/** + * Helper: Check if model has thinking capability + */ +export function codexModelHasThinking(modelId: CodexModelId): boolean { + return CODEX_MODEL_CONFIG_MAP[modelId]?.hasThinking ?? false; +} + +/** + * Helper: Get display name for model + */ +export function getCodexModelLabel(modelId: CodexModelId): string { + return CODEX_MODEL_CONFIG_MAP[modelId]?.label ?? modelId; +} + +/** + * Helper: Get all Codex model IDs + */ +export function getAllCodexModelIds(): CodexModelId[] { + return Object.keys(CODEX_MODEL_CONFIG_MAP) as CodexModelId[]; +} + +/** + * Helper: Check if Codex model supports vision + */ +export function codexModelSupportsVision(modelId: CodexModelId): boolean { + return CODEX_MODEL_CONFIG_MAP[modelId]?.supportsVision ?? true; +} diff --git a/libs/types/src/codex.ts b/libs/types/src/codex.ts index 388e5890..44ac981a 100644 --- a/libs/types/src/codex.ts +++ b/libs/types/src/codex.ts @@ -42,3 +42,11 @@ export interface CodexCliConfig { /** List of enabled models */ models?: string[]; } + +/** Codex authentication status */ +export interface CodexAuthStatus { + authenticated: boolean; + method: 'oauth' | 'api_key' | 'none'; + hasCredentialsFile?: boolean; + error?: string; +} diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index a48cc76d..9d2854c5 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -21,7 +21,13 @@ export type { } from './provider.js'; // Codex CLI types -export type { CodexSandboxMode, CodexApprovalPolicy, CodexCliConfig } from './codex.js'; +export type { + CodexSandboxMode, + CodexApprovalPolicy, + CodexCliConfig, + CodexAuthStatus, +} from './codex.js'; +export * from './codex-models.js'; // Feature types export type { Feature, FeatureImagePath, FeatureTextFilePath, FeatureStatus } from './feature.js';