From a57dcc170d2530d6cc1e00875f133eb6a3d7fa54 Mon Sep 17 00:00:00 2001 From: DhanushSantosh Date: Tue, 6 Jan 2026 04:52:25 +0530 Subject: [PATCH] feature/codex-cli --- apps/server/package.json | 1 + apps/server/src/index.ts | 4 +- .../src/providers/codex-config-manager.ts | 85 ++ apps/server/src/providers/codex-models.ts | 123 +++ apps/server/src/providers/codex-provider.ts | 987 ++++++++++++++++++ apps/server/src/providers/codex-sdk-client.ts | 173 +++ .../src/providers/codex-tool-mapping.ts | 385 +++++++ apps/server/src/providers/cursor-provider.ts | 59 +- apps/server/src/providers/provider-factory.ts | 11 +- apps/server/src/routes/setup/index.ts | 10 + .../src/routes/setup/routes/auth-codex.ts | 31 + .../src/routes/setup/routes/codex-status.ts | 43 + .../src/routes/setup/routes/install-codex.ts | 33 + .../routes/setup/routes/verify-codex-auth.ts | 232 ++++ .../unit/providers/codex-provider.test.ts | 290 +++++ .../unit/providers/provider-factory.test.ts | 7 +- apps/ui/src/components/ui/provider-icon.tsx | 154 +++ .../board-view/shared/model-constants.ts | 87 +- .../board-view/shared/model-selector.tsx | 94 +- .../profiles-view/components/profile-form.tsx | 120 ++- .../cli-status/claude-cli-status.tsx | 5 +- .../cli-status/cli-status-card.tsx | 151 +++ .../cli-status/codex-cli-status.tsx | 24 + .../cli-status/cursor-cli-status.tsx | 5 +- .../settings-view/codex/codex-settings.tsx | 250 +++++ .../codex/codex-usage-section.tsx | 237 +++++ .../model-defaults/phase-model-selector.tsx | 97 +- .../providers/codex-settings-tab.tsx | 92 ++ .../views/settings-view/providers/index.ts | 1 + .../settings-view/providers/provider-tabs.tsx | 19 +- apps/ui/src/components/views/setup-view.tsx | 26 +- .../views/setup-view/hooks/use-cli-status.ts | 57 +- .../views/setup-view/steps/cli-setup-step.tsx | 809 ++++++++++++++ .../setup-view/steps/codex-setup-step.tsx | 102 ++ .../views/setup-view/steps/index.ts | 1 + apps/ui/src/hooks/use-settings-migration.ts | 7 + apps/ui/src/lib/agent-context-parser.ts | 22 + apps/ui/src/lib/codex-usage-format.ts | 86 ++ apps/ui/src/lib/electron.ts | 45 + apps/ui/src/lib/http-api-client.ts | 45 + apps/ui/src/lib/utils.ts | 38 +- apps/ui/src/store/setup-store.ts | 65 ++ libs/model-resolver/src/resolver.ts | 36 +- libs/model-resolver/tests/resolver.test.ts | 2 +- libs/platform/src/index.ts | 6 + libs/platform/src/system-paths.ts | 107 ++ libs/types/src/codex.ts | 44 + libs/types/src/index.ts | 22 +- libs/types/src/model-display.ts | 101 ++ libs/types/src/model.ts | 58 + libs/types/src/provider-utils.ts | 57 +- libs/types/src/provider.ts | 44 + libs/types/src/settings.ts | 51 +- package-lock.json | 12 +- 54 files changed, 5562 insertions(+), 91 deletions(-) create mode 100644 apps/server/src/providers/codex-config-manager.ts create mode 100644 apps/server/src/providers/codex-models.ts create mode 100644 apps/server/src/providers/codex-provider.ts create mode 100644 apps/server/src/providers/codex-sdk-client.ts create mode 100644 apps/server/src/providers/codex-tool-mapping.ts create mode 100644 apps/server/src/routes/setup/routes/auth-codex.ts create mode 100644 apps/server/src/routes/setup/routes/codex-status.ts create mode 100644 apps/server/src/routes/setup/routes/install-codex.ts create mode 100644 apps/server/src/routes/setup/routes/verify-codex-auth.ts create mode 100644 apps/server/tests/unit/providers/codex-provider.test.ts create mode 100644 apps/ui/src/components/ui/provider-icon.tsx create mode 100644 apps/ui/src/components/views/settings-view/cli-status/cli-status-card.tsx create mode 100644 apps/ui/src/components/views/settings-view/cli-status/codex-cli-status.tsx create mode 100644 apps/ui/src/components/views/settings-view/codex/codex-settings.tsx create mode 100644 apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx create mode 100644 apps/ui/src/components/views/settings-view/providers/codex-settings-tab.tsx create mode 100644 apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx create mode 100644 apps/ui/src/components/views/setup-view/steps/codex-setup-step.tsx create mode 100644 apps/ui/src/lib/codex-usage-format.ts create mode 100644 libs/types/src/codex.ts diff --git a/apps/server/package.json b/apps/server/package.json index 5baf99fc..8d26339a 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -33,6 +33,7 @@ "@automaker/types": "1.0.0", "@automaker/utils": "1.0.0", "@modelcontextprotocol/sdk": "1.25.1", + "@openai/codex-sdk": "^0.77.0", "cookie-parser": "1.4.7", "cors": "2.8.5", "dotenv": "17.2.3", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 9ba53ed8..11088a3c 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -188,9 +188,10 @@ setInterval(() => { // This helps prevent CSRF and content-type confusion attacks app.use('/api', requireJsonContentType); -// Mount API routes - health and auth are unauthenticated +// Mount API routes - health, auth, and setup are unauthenticated app.use('/api/health', createHealthRoutes()); app.use('/api/auth', createAuthRoutes()); +app.use('/api/setup', createSetupRoutes()); // Apply authentication to all other routes app.use('/api', authMiddleware); @@ -206,7 +207,6 @@ app.use('/api/auto-mode', createAutoModeRoutes(autoModeService)); app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService)); app.use('/api/worktree', createWorktreeRoutes()); app.use('/api/git', createGitRoutes()); -app.use('/api/setup', createSetupRoutes()); app.use('/api/suggestions', createSuggestionsRoutes(events, settingsService)); app.use('/api/models', createModelsRoutes()); app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService)); 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..33031c4a --- /dev/null +++ b/apps/server/src/providers/codex-config-manager.ts @@ -0,0 +1,85 @@ +/** + * Codex Config Manager - Writes MCP server configuration for Codex CLI + */ + +import path from 'path'; +import type { McpServerConfig } from '@automaker/types'; +import * as secureFs from '../lib/secure-fs.js'; + +const CODEX_CONFIG_DIR = '.codex'; +const CODEX_CONFIG_FILENAME = 'config.toml'; +const CODEX_MCP_SECTION = 'mcp_servers'; + +function formatTomlString(value: string): string { + return JSON.stringify(value); +} + +function formatTomlArray(values: string[]): string { + const formatted = values.map((value) => formatTomlString(value)).join(', '); + return `[${formatted}]`; +} + +function formatTomlInlineTable(values: Record): string { + const entries = Object.entries(values).map( + ([key, value]) => `${key} = ${formatTomlString(value)}` + ); + return `{ ${entries.join(', ')} }`; +} + +function formatTomlKey(key: string): string { + return `"${key.replace(/"/g, '\\"')}"`; +} + +function buildServerBlock(name: string, server: McpServerConfig): string[] { + const lines: string[] = []; + const section = `${CODEX_MCP_SECTION}.${formatTomlKey(name)}`; + lines.push(`[${section}]`); + + if (server.type) { + lines.push(`type = ${formatTomlString(server.type)}`); + } + + if ('command' in server && server.command) { + lines.push(`command = ${formatTomlString(server.command)}`); + } + + if ('args' in server && server.args && server.args.length > 0) { + lines.push(`args = ${formatTomlArray(server.args)}`); + } + + if ('env' in server && server.env && Object.keys(server.env).length > 0) { + lines.push(`env = ${formatTomlInlineTable(server.env)}`); + } + + if ('url' in server && server.url) { + lines.push(`url = ${formatTomlString(server.url)}`); + } + + if ('headers' in server && server.headers && Object.keys(server.headers).length > 0) { + lines.push(`headers = ${formatTomlInlineTable(server.headers)}`); + } + + return lines; +} + +export class CodexConfigManager { + async configureMcpServers( + cwd: string, + mcpServers: Record + ): Promise { + const configDir = path.join(cwd, CODEX_CONFIG_DIR); + const configPath = path.join(configDir, CODEX_CONFIG_FILENAME); + + await secureFs.mkdir(configDir, { recursive: true }); + + const blocks: string[] = []; + for (const [name, server] of Object.entries(mcpServers)) { + blocks.push(...buildServerBlock(name, server), ''); + } + + const content = blocks.join('\n').trim(); + if (content) { + await secureFs.writeFile(configPath, content + '\n', 'utf-8'); + } + } +} diff --git a/apps/server/src/providers/codex-models.ts b/apps/server/src/providers/codex-models.ts new file mode 100644 index 00000000..14dd566f --- /dev/null +++ b/apps/server/src/providers/codex-models.ts @@ -0,0 +1,123 @@ +/** + * Codex Model Definitions + * + * Official Codex CLI models as documented at https://developers.openai.com/codex/models/ + */ + +import { CODEX_MODEL_MAP } from '@automaker/types'; +import type { ModelDefinition } from './types.js'; + +const CONTEXT_WINDOW_200K = 200000; +const CONTEXT_WINDOW_128K = 128000; +const MAX_OUTPUT_32K = 32000; +const MAX_OUTPUT_16K = 16000; + +/** + * All available Codex models with their specifications + */ +export const CODEX_MODELS: ModelDefinition[] = [ + // ========== Codex-Specific Models ========== + { + id: CODEX_MODEL_MAP.gpt52Codex, + name: 'GPT-5.2-Codex', + modelString: CODEX_MODEL_MAP.gpt52Codex, + provider: 'openai', + description: + 'Most advanced agentic coding model for complex software engineering (default for ChatGPT users).', + contextWindow: CONTEXT_WINDOW_200K, + maxOutputTokens: MAX_OUTPUT_32K, + supportsVision: true, + supportsTools: true, + tier: 'premium' as const, + default: true, + hasReasoning: true, + }, + { + id: CODEX_MODEL_MAP.gpt5Codex, + name: 'GPT-5-Codex', + modelString: CODEX_MODEL_MAP.gpt5Codex, + provider: 'openai', + description: 'Purpose-built for Codex CLI with versatile tool use (default for CLI users).', + contextWindow: CONTEXT_WINDOW_200K, + maxOutputTokens: MAX_OUTPUT_32K, + supportsVision: true, + supportsTools: true, + tier: 'standard' as const, + hasReasoning: true, + }, + { + id: CODEX_MODEL_MAP.gpt5CodexMini, + name: 'GPT-5-Codex-Mini', + modelString: CODEX_MODEL_MAP.gpt5CodexMini, + provider: 'openai', + description: 'Faster workflows optimized for low-latency code Q&A and editing.', + contextWindow: CONTEXT_WINDOW_128K, + maxOutputTokens: MAX_OUTPUT_16K, + supportsVision: false, + supportsTools: true, + tier: 'basic' as const, + hasReasoning: false, + }, + { + id: CODEX_MODEL_MAP.codex1, + name: 'Codex-1', + modelString: CODEX_MODEL_MAP.codex1, + provider: 'openai', + description: 'Version of o3 optimized for software engineering with advanced reasoning.', + contextWindow: CONTEXT_WINDOW_200K, + maxOutputTokens: MAX_OUTPUT_32K, + supportsVision: true, + supportsTools: true, + tier: 'premium' as const, + hasReasoning: true, + }, + { + id: CODEX_MODEL_MAP.codexMiniLatest, + name: 'Codex-Mini-Latest', + modelString: CODEX_MODEL_MAP.codexMiniLatest, + provider: 'openai', + description: 'Version of o4-mini designed for Codex with faster workflows.', + contextWindow: CONTEXT_WINDOW_128K, + maxOutputTokens: MAX_OUTPUT_16K, + supportsVision: true, + supportsTools: true, + tier: 'standard' as const, + hasReasoning: false, + }, + + // ========== Base GPT-5 Model ========== + { + id: CODEX_MODEL_MAP.gpt5, + name: 'GPT-5', + modelString: CODEX_MODEL_MAP.gpt5, + provider: 'openai', + description: 'GPT-5 base flagship model with strong general-purpose capabilities.', + contextWindow: CONTEXT_WINDOW_200K, + maxOutputTokens: MAX_OUTPUT_32K, + supportsVision: true, + supportsTools: true, + tier: 'standard' as const, + hasReasoning: true, + }, +]; + +/** + * Get model definition by ID + */ +export function getCodexModelById(modelId: string): ModelDefinition | undefined { + return CODEX_MODELS.find((m) => m.id === modelId || m.modelString === modelId); +} + +/** + * Get all models that support reasoning + */ +export function getReasoningModels(): ModelDefinition[] { + return CODEX_MODELS.filter((m) => m.hasReasoning); +} + +/** + * Get models by tier + */ +export function getModelsByTier(tier: 'premium' | 'standard' | 'basic'): ModelDefinition[] { + return CODEX_MODELS.filter((m) => m.tier === tier); +} diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts new file mode 100644 index 00000000..4f1f2c35 --- /dev/null +++ b/apps/server/src/providers/codex-provider.ts @@ -0,0 +1,987 @@ +/** + * Codex Provider - Executes queries using Codex CLI + * + * Spawns the Codex CLI and converts JSONL output into ProviderMessage format. + */ + +import path from 'path'; +import { BaseProvider } from './base-provider.js'; +import { + spawnJSONLProcess, + spawnProcess, + findCodexCliPath, + getCodexAuthIndicators, + secureFs, + getDataDirectory, + getCodexConfigDir, +} from '@automaker/platform'; +import { + formatHistoryAsText, + extractTextFromContent, + classifyError, + getUserFriendlyErrorMessage, +} from '@automaker/utils'; +import type { + ExecuteOptions, + ProviderMessage, + InstallationStatus, + ModelDefinition, +} from './types.js'; +import { + CODEX_MODEL_MAP, + supportsReasoningEffort, + type CodexApprovalPolicy, + type CodexSandboxMode, +} from '@automaker/types'; +import { CodexConfigManager } from './codex-config-manager.js'; +import { executeCodexSdkQuery } from './codex-sdk-client.js'; +import { + resolveCodexToolCall, + extractCodexTodoItems, + getCodexTodoToolName, +} from './codex-tool-mapping.js'; +import { SettingsService } from '../services/settings-service.js'; +import { checkSandboxCompatibility } from '../lib/sdk-options.js'; +import { CODEX_MODELS } from './codex-models.js'; + +const CODEX_COMMAND = 'codex'; +const CODEX_EXEC_SUBCOMMAND = 'exec'; +const CODEX_JSON_FLAG = '--json'; +const CODEX_MODEL_FLAG = '--model'; +const CODEX_VERSION_FLAG = '--version'; +const CODEX_SANDBOX_FLAG = '--sandbox'; +const CODEX_APPROVAL_FLAG = '--ask-for-approval'; +const CODEX_SEARCH_FLAG = '--search'; +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_RESUME_FLAG = 'resume'; +const CODEX_REASONING_EFFORT_KEY = 'reasoning_effort'; +const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY'; +const CODEX_EXECUTION_MODE_CLI = 'cli'; +const CODEX_EXECUTION_MODE_SDK = 'sdk'; +const ERROR_CODEX_CLI_REQUIRED = + 'Codex CLI is required for tool-enabled requests. Please install Codex CLI and run `codex login`.'; +const ERROR_CODEX_AUTH_REQUIRED = "Codex authentication is required. Please run 'codex login'."; +const ERROR_CODEX_SDK_AUTH_REQUIRED = 'OpenAI API key required for Codex SDK execution.'; + +const CODEX_EVENT_TYPES = { + itemCompleted: 'item.completed', + itemStarted: 'item.started', + itemUpdated: 'item.updated', + threadCompleted: 'thread.completed', + error: 'error', +} as const; + +const CODEX_ITEM_TYPES = { + reasoning: 'reasoning', + agentMessage: 'agent_message', + commandExecution: 'command_execution', + todoList: 'todo_list', +} as const; + +const SYSTEM_PROMPT_LABEL = 'System instructions'; +const HISTORY_HEADER = 'Current request:\n'; +const TEXT_ENCODING = 'utf-8'; +const DEFAULT_TIMEOUT_MS = 30000; +const CONTEXT_WINDOW_256K = 256000; +const MAX_OUTPUT_32K = 32000; +const MAX_OUTPUT_16K = 16000; +const SYSTEM_PROMPT_SEPARATOR = '\n\n'; +const CODEX_INSTRUCTIONS_DIR = '.codex'; +const CODEX_INSTRUCTIONS_SECTION = 'Codex Project Instructions'; +const CODEX_INSTRUCTIONS_PATH_LABEL = 'Path'; +const CODEX_INSTRUCTIONS_SOURCE_LABEL = 'Source'; +const CODEX_INSTRUCTIONS_USER_SOURCE = 'User instructions'; +const CODEX_INSTRUCTIONS_PROJECT_SOURCE = 'Project instructions'; +const CODEX_USER_INSTRUCTIONS_FILE = 'AGENTS.md'; +const CODEX_PROJECT_INSTRUCTIONS_FILES = ['AGENTS.md'] as const; +const CODEX_SETTINGS_DIR_FALLBACK = './data'; +const DEFAULT_CODEX_AUTO_LOAD_AGENTS = false; +const DEFAULT_CODEX_SANDBOX_MODE: CodexSandboxMode = 'workspace-write'; +const DEFAULT_CODEX_APPROVAL_POLICY: CodexApprovalPolicy = 'on-request'; +const TOOL_USE_ID_PREFIX = 'codex-tool-'; +const ITEM_ID_KEYS = ['id', 'item_id', 'call_id', 'tool_use_id', 'command_id'] as const; +const EVENT_ID_KEYS = ['id', 'event_id', 'request_id'] as const; +const COMMAND_OUTPUT_FIELDS = ['output', 'stdout', 'stderr', 'result'] as const; +const COMMAND_OUTPUT_SEPARATOR = '\n'; +const OUTPUT_SCHEMA_FILENAME = 'output-schema.json'; +const OUTPUT_SCHEMA_INDENT_SPACES = 2; +const IMAGE_TEMP_DIR = '.codex-images'; +const IMAGE_FILE_PREFIX = 'image-'; +const IMAGE_FILE_EXT = '.png'; +const DEFAULT_ALLOWED_TOOLS = [ + 'Read', + 'Write', + 'Edit', + 'Glob', + 'Grep', + 'Bash', + 'WebSearch', + 'WebFetch', +] as const; +const SEARCH_TOOL_NAMES = new Set(['WebSearch', 'WebFetch']); +const MIN_MAX_TURNS = 1; +const CONFIG_KEY_MAX_TURNS = 'max_turns'; +const CONSTRAINTS_SECTION_TITLE = 'Codex Execution Constraints'; +const CONSTRAINTS_MAX_TURNS_LABEL = 'Max turns'; +const CONSTRAINTS_ALLOWED_TOOLS_LABEL = 'Allowed tools'; +const CONSTRAINTS_OUTPUT_SCHEMA_LABEL = 'Output format'; +const CONSTRAINTS_SESSION_ID_LABEL = 'Session ID'; +const CONSTRAINTS_NO_TOOLS_VALUE = 'none'; +const CONSTRAINTS_OUTPUT_SCHEMA_VALUE = 'Respond with JSON that matches the provided schema.'; + +type CodexExecutionMode = typeof CODEX_EXECUTION_MODE_CLI | typeof CODEX_EXECUTION_MODE_SDK; +type CodexExecutionPlan = { + mode: CodexExecutionMode; + cliPath: string | null; +}; + +const ALLOWED_ENV_VARS = [ + OPENAI_API_KEY_ENV, + 'PATH', + 'HOME', + 'SHELL', + 'TERM', + 'USER', + 'LANG', + 'LC_ALL', +]; + +function buildEnv(): Record { + const env: Record = {}; + for (const key of ALLOWED_ENV_VARS) { + const value = process.env[key]; + if (value) { + env[key] = value; + } + } + return env; +} + +function hasMcpServersConfigured(options: ExecuteOptions): boolean { + return Boolean(options.mcpServers && Object.keys(options.mcpServers).length > 0); +} + +function isNoToolsRequested(options: ExecuteOptions): boolean { + return Array.isArray(options.allowedTools) && options.allowedTools.length === 0; +} + +function isSdkEligible(options: ExecuteOptions): boolean { + return isNoToolsRequested(options) && !hasMcpServersConfigured(options); +} + +async function resolveCodexExecutionPlan(options: ExecuteOptions): Promise { + const cliPath = await findCodexCliPath(); + const authIndicators = await getCodexAuthIndicators(); + const hasApiKey = Boolean(process.env[OPENAI_API_KEY_ENV]); + const cliAuthenticated = authIndicators.hasOAuthToken || authIndicators.hasApiKey || hasApiKey; + const sdkEligible = isSdkEligible(options); + const cliAvailable = Boolean(cliPath); + + if (sdkEligible) { + if (hasApiKey) { + return { + mode: CODEX_EXECUTION_MODE_SDK, + cliPath, + }; + } + if (!cliAvailable) { + throw new Error(ERROR_CODEX_SDK_AUTH_REQUIRED); + } + } + + if (!cliAvailable) { + throw new Error(ERROR_CODEX_CLI_REQUIRED); + } + + if (!cliAuthenticated) { + throw new Error(ERROR_CODEX_AUTH_REQUIRED); + } + + return { + mode: CODEX_EXECUTION_MODE_CLI, + cliPath, + }; +} + +function getEventType(event: Record): string | null { + if (typeof event.type === 'string') { + return event.type; + } + if (typeof event.event === 'string') { + return event.event; + } + return null; +} + +function extractText(value: unknown): string | null { + if (typeof value === 'string') { + return value; + } + if (Array.isArray(value)) { + return value + .map((item) => extractText(item)) + .filter(Boolean) + .join('\n'); + } + if (value && typeof value === 'object') { + const record = value as Record; + if (typeof record.text === 'string') { + return record.text; + } + if (typeof record.content === 'string') { + return record.content; + } + if (typeof record.message === 'string') { + return record.message; + } + } + return null; +} + +function extractCommandText(item: Record): string | null { + const direct = extractText(item.command ?? item.input ?? item.content); + if (direct) { + return direct; + } + return null; +} + +function extractCommandOutput(item: Record): string | null { + const outputs: string[] = []; + for (const field of COMMAND_OUTPUT_FIELDS) { + const value = item[field]; + const text = extractText(value); + if (text) { + outputs.push(text); + } + } + + if (outputs.length === 0) { + return null; + } + + const uniqueOutputs = outputs.filter((output, index) => outputs.indexOf(output) === index); + return uniqueOutputs.join(COMMAND_OUTPUT_SEPARATOR); +} + +function extractItemType(item: Record): string | null { + if (typeof item.type === 'string') { + return item.type; + } + if (typeof item.kind === 'string') { + return item.kind; + } + return null; +} + +function resolveSystemPrompt(systemPrompt?: unknown): string | null { + if (!systemPrompt) { + return null; + } + if (typeof systemPrompt === 'string') { + return systemPrompt; + } + if (typeof systemPrompt === 'object' && systemPrompt !== null) { + const record = systemPrompt as Record; + if (typeof record.append === 'string') { + return record.append; + } + } + return null; +} + +function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string | null): string { + const promptText = + typeof options.prompt === 'string' ? options.prompt : extractTextFromContent(options.prompt); + const historyText = options.conversationHistory + ? formatHistoryAsText(options.conversationHistory) + : ''; + const resolvedSystemPrompt = systemPromptText ?? resolveSystemPrompt(options.systemPrompt); + + const systemSection = resolvedSystemPrompt + ? `${SYSTEM_PROMPT_LABEL}:\n${resolvedSystemPrompt}\n\n` + : ''; + + return `${historyText}${systemSection}${HISTORY_HEADER}${promptText}`; +} + +function formatConfigValue(value: string | number | boolean): string { + if (typeof value === 'string') { + return JSON.stringify(value); + } + return String(value); +} + +function buildConfigOverrides( + overrides: Array<{ key: string; value: string | number | boolean }> +): string[] { + const args: string[] = []; + for (const override of overrides) { + args.push(CODEX_CONFIG_FLAG, `${override.key}=${formatConfigValue(override.value)}`); + } + return args; +} + +function resolveMaxTurns(maxTurns?: number): number | null { + if (typeof maxTurns !== 'number' || Number.isNaN(maxTurns) || !Number.isFinite(maxTurns)) { + return null; + } + const normalized = Math.floor(maxTurns); + return normalized >= MIN_MAX_TURNS ? normalized : null; +} + +function resolveSearchEnabled(allowedTools: string[], restrictTools: boolean): boolean { + const toolsToCheck = restrictTools ? allowedTools : Array.from(DEFAULT_ALLOWED_TOOLS); + return toolsToCheck.some((tool) => SEARCH_TOOL_NAMES.has(tool)); +} + +function buildCodexConstraintsPrompt( + options: ExecuteOptions, + config: { + allowedTools: string[]; + restrictTools: boolean; + maxTurns: number | null; + hasOutputSchema: boolean; + } +): string | null { + const lines: string[] = []; + + if (config.maxTurns !== null) { + lines.push(`${CONSTRAINTS_MAX_TURNS_LABEL}: ${config.maxTurns}`); + } + + if (config.restrictTools) { + const allowed = + config.allowedTools.length > 0 ? config.allowedTools.join(', ') : CONSTRAINTS_NO_TOOLS_VALUE; + lines.push(`${CONSTRAINTS_ALLOWED_TOOLS_LABEL}: ${allowed}`); + } + + if (config.hasOutputSchema) { + lines.push(`${CONSTRAINTS_OUTPUT_SCHEMA_LABEL}: ${CONSTRAINTS_OUTPUT_SCHEMA_VALUE}`); + } + + if (options.sdkSessionId) { + lines.push(`${CONSTRAINTS_SESSION_ID_LABEL}: ${options.sdkSessionId}`); + } + + if (lines.length === 0) { + return null; + } + + return `## ${CONSTRAINTS_SECTION_TITLE}\n${lines.map((line) => `- ${line}`).join('\n')}`; +} + +async function writeOutputSchemaFile( + cwd: string, + outputFormat?: ExecuteOptions['outputFormat'] +): Promise { + if (!outputFormat || outputFormat.type !== 'json_schema') { + return null; + } + if (!outputFormat.schema || typeof outputFormat.schema !== 'object') { + throw new Error('Codex output schema must be a JSON object.'); + } + + const schemaDir = path.join(cwd, CODEX_INSTRUCTIONS_DIR); + await secureFs.mkdir(schemaDir, { recursive: true }); + const schemaPath = path.join(schemaDir, OUTPUT_SCHEMA_FILENAME); + const schemaContent = JSON.stringify(outputFormat.schema, null, OUTPUT_SCHEMA_INDENT_SPACES); + await secureFs.writeFile(schemaPath, schemaContent, TEXT_ENCODING); + return schemaPath; +} + +type ImageBlock = { + type: 'image'; + source: { + type: string; + media_type: string; + data: string; + }; +}; + +function extractImageBlocks(prompt: ExecuteOptions['prompt']): ImageBlock[] { + if (typeof prompt === 'string') { + return []; + } + if (!Array.isArray(prompt)) { + return []; + } + + const images: ImageBlock[] = []; + for (const block of prompt) { + if ( + block && + typeof block === 'object' && + 'type' in block && + block.type === 'image' && + 'source' in block && + block.source && + typeof block.source === 'object' && + 'data' in block.source && + 'media_type' in block.source + ) { + images.push(block as ImageBlock); + } + } + return images; +} + +async function writeImageFiles(cwd: string, imageBlocks: ImageBlock[]): Promise { + if (imageBlocks.length === 0) { + return []; + } + + const imageDir = path.join(cwd, CODEX_INSTRUCTIONS_DIR, IMAGE_TEMP_DIR); + await secureFs.mkdir(imageDir, { recursive: true }); + + const imagePaths: string[] = []; + for (let i = 0; i < imageBlocks.length; i++) { + const imageBlock = imageBlocks[i]; + const imageName = `${IMAGE_FILE_PREFIX}${Date.now()}-${i}${IMAGE_FILE_EXT}`; + const imagePath = path.join(imageDir, imageName); + + // Convert base64 to buffer + const imageData = Buffer.from(imageBlock.source.data, 'base64'); + await secureFs.writeFile(imagePath, imageData); + imagePaths.push(imagePath); + } + + return imagePaths; +} + +function normalizeIdentifier(value: unknown): string | null { + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed ? trimmed : null; + } + if (typeof value === 'number' && Number.isFinite(value)) { + return String(value); + } + return null; +} + +function getIdentifierFromRecord( + record: Record, + keys: readonly string[] +): string | null { + for (const key of keys) { + const id = normalizeIdentifier(record[key]); + if (id) { + return id; + } + } + return null; +} + +function getItemIdentifier( + event: Record, + item: Record +): string | null { + return ( + getIdentifierFromRecord(item, ITEM_ID_KEYS) ?? getIdentifierFromRecord(event, EVENT_ID_KEYS) + ); +} + +class CodexToolUseTracker { + private readonly toolUseIdsByItem = new Map(); + private readonly anonymousToolUses: string[] = []; + private sequence = 0; + + register(event: Record, item: Record): string { + const itemId = getItemIdentifier(event, item); + const toolUseId = this.nextToolUseId(); + if (itemId) { + this.toolUseIdsByItem.set(itemId, toolUseId); + } else { + this.anonymousToolUses.push(toolUseId); + } + return toolUseId; + } + + resolve(event: Record, item: Record): string | null { + const itemId = getItemIdentifier(event, item); + if (itemId) { + const toolUseId = this.toolUseIdsByItem.get(itemId); + if (toolUseId) { + this.toolUseIdsByItem.delete(itemId); + return toolUseId; + } + } + + if (this.anonymousToolUses.length > 0) { + return this.anonymousToolUses.shift() || null; + } + + return null; + } + + private nextToolUseId(): string { + this.sequence += 1; + return `${TOOL_USE_ID_PREFIX}${this.sequence}`; + } +} + +type CodexCliSettings = { + autoLoadAgents: boolean; + sandboxMode: CodexSandboxMode; + approvalPolicy: CodexApprovalPolicy; + enableWebSearch: boolean; + enableImages: boolean; + additionalDirs: string[]; + threadId?: string; +}; + +function getCodexSettingsDir(): string { + const configured = getDataDirectory() ?? process.env.DATA_DIR; + return configured ? path.resolve(configured) : path.resolve(CODEX_SETTINGS_DIR_FALLBACK); +} + +async function loadCodexCliSettings( + overrides?: ExecuteOptions['codexSettings'] +): Promise { + const defaults: CodexCliSettings = { + autoLoadAgents: DEFAULT_CODEX_AUTO_LOAD_AGENTS, + sandboxMode: DEFAULT_CODEX_SANDBOX_MODE, + approvalPolicy: DEFAULT_CODEX_APPROVAL_POLICY, + enableWebSearch: false, + enableImages: true, + additionalDirs: [], + threadId: undefined, + }; + + try { + const settingsService = new SettingsService(getCodexSettingsDir()); + const settings = await settingsService.getGlobalSettings(); + const resolved: CodexCliSettings = { + autoLoadAgents: settings.codexAutoLoadAgents ?? defaults.autoLoadAgents, + sandboxMode: settings.codexSandboxMode ?? defaults.sandboxMode, + approvalPolicy: settings.codexApprovalPolicy ?? defaults.approvalPolicy, + enableWebSearch: settings.codexEnableWebSearch ?? defaults.enableWebSearch, + enableImages: settings.codexEnableImages ?? defaults.enableImages, + additionalDirs: settings.codexAdditionalDirs ?? defaults.additionalDirs, + threadId: settings.codexThreadId, + }; + + if (!overrides) { + return resolved; + } + + return { + autoLoadAgents: overrides.autoLoadAgents ?? resolved.autoLoadAgents, + sandboxMode: overrides.sandboxMode ?? resolved.sandboxMode, + approvalPolicy: overrides.approvalPolicy ?? resolved.approvalPolicy, + enableWebSearch: overrides.enableWebSearch ?? resolved.enableWebSearch, + enableImages: overrides.enableImages ?? resolved.enableImages, + additionalDirs: overrides.additionalDirs ?? resolved.additionalDirs, + threadId: overrides.threadId ?? resolved.threadId, + }; + } catch { + return { + autoLoadAgents: overrides?.autoLoadAgents ?? defaults.autoLoadAgents, + sandboxMode: overrides?.sandboxMode ?? defaults.sandboxMode, + approvalPolicy: overrides?.approvalPolicy ?? defaults.approvalPolicy, + enableWebSearch: overrides?.enableWebSearch ?? defaults.enableWebSearch, + enableImages: overrides?.enableImages ?? defaults.enableImages, + additionalDirs: overrides?.additionalDirs ?? defaults.additionalDirs, + threadId: overrides?.threadId ?? defaults.threadId, + }; + } +} + +function buildCodexInstructionsPrompt( + filePath: string, + content: string, + sourceLabel: string +): string { + return `## ${CODEX_INSTRUCTIONS_SECTION}\n**${CODEX_INSTRUCTIONS_SOURCE_LABEL}:** ${sourceLabel}\n**${CODEX_INSTRUCTIONS_PATH_LABEL}:** \`${filePath}\`\n\n${content}`; +} + +async function readCodexInstructionFile(filePath: string): Promise { + try { + const raw = await secureFs.readFile(filePath, TEXT_ENCODING); + const content = String(raw).trim(); + return content ? content : null; + } catch { + return null; + } +} + +async function loadCodexInstructions(cwd: string, enabled: boolean): Promise { + if (!enabled) { + return null; + } + + const sources: Array<{ path: string; content: string; sourceLabel: string }> = []; + const userInstructionsPath = path.join(getCodexConfigDir(), CODEX_USER_INSTRUCTIONS_FILE); + const userContent = await readCodexInstructionFile(userInstructionsPath); + if (userContent) { + sources.push({ + path: userInstructionsPath, + content: userContent, + sourceLabel: CODEX_INSTRUCTIONS_USER_SOURCE, + }); + } + + for (const fileName of CODEX_PROJECT_INSTRUCTIONS_FILES) { + const projectPath = path.join(cwd, CODEX_INSTRUCTIONS_DIR, fileName); + const projectContent = await readCodexInstructionFile(projectPath); + if (projectContent) { + sources.push({ + path: projectPath, + content: projectContent, + sourceLabel: CODEX_INSTRUCTIONS_PROJECT_SOURCE, + }); + } + } + + if (sources.length === 0) { + return null; + } + + const seen = new Set(); + const uniqueSources = sources.filter((source) => { + const normalized = source.content.trim(); + if (seen.has(normalized)) { + return false; + } + seen.add(normalized); + return true; + }); + + return uniqueSources + .map((source) => buildCodexInstructionsPrompt(source.path, source.content, source.sourceLabel)) + .join('\n\n'); +} + +export class CodexProvider extends BaseProvider { + getName(): string { + return 'codex'; + } + + async *executeQuery(options: ExecuteOptions): AsyncGenerator { + try { + const mcpServers = options.mcpServers ?? {}; + const hasMcpServers = Object.keys(mcpServers).length > 0; + const codexSettings = await loadCodexCliSettings(options.codexSettings); + const codexInstructions = await loadCodexInstructions( + options.cwd, + codexSettings.autoLoadAgents + ); + const baseSystemPrompt = resolveSystemPrompt(options.systemPrompt); + const resolvedMaxTurns = resolveMaxTurns(options.maxTurns); + const resolvedAllowedTools = options.allowedTools ?? Array.from(DEFAULT_ALLOWED_TOOLS); + const restrictTools = !hasMcpServers || options.mcpUnrestrictedTools === false; + const wantsOutputSchema = Boolean( + options.outputFormat && options.outputFormat.type === 'json_schema' + ); + const constraintsPrompt = buildCodexConstraintsPrompt(options, { + allowedTools: resolvedAllowedTools, + restrictTools, + maxTurns: resolvedMaxTurns, + hasOutputSchema: wantsOutputSchema, + }); + const systemPromptParts = [codexInstructions, baseSystemPrompt, constraintsPrompt].filter( + (part): part is string => Boolean(part) + ); + const combinedSystemPrompt = systemPromptParts.length + ? systemPromptParts.join(SYSTEM_PROMPT_SEPARATOR) + : null; + + const executionPlan = await resolveCodexExecutionPlan(options); + if (executionPlan.mode === CODEX_EXECUTION_MODE_SDK) { + yield* executeCodexSdkQuery(options, combinedSystemPrompt); + return; + } + + if (hasMcpServers) { + const configManager = new CodexConfigManager(); + await configManager.configureMcpServers(options.cwd, options.mcpServers!); + } + + const toolUseTracker = new CodexToolUseTracker(); + const sandboxCheck = checkSandboxCompatibility( + options.cwd, + codexSettings.sandboxMode !== 'danger-full-access' + ); + const resolvedSandboxMode = sandboxCheck.enabled + ? codexSettings.sandboxMode + : 'danger-full-access'; + if (!sandboxCheck.enabled && sandboxCheck.message) { + console.warn(`[CodexProvider] ${sandboxCheck.message}`); + } + const searchEnabled = + codexSettings.enableWebSearch || resolveSearchEnabled(resolvedAllowedTools, restrictTools); + const outputSchemaPath = await writeOutputSchemaFile(options.cwd, options.outputFormat); + const imageBlocks = codexSettings.enableImages ? extractImageBlocks(options.prompt) : []; + const imagePaths = await writeImageFiles(options.cwd, imageBlocks); + const approvalPolicy = + hasMcpServers && options.mcpAutoApproveTools !== undefined + ? options.mcpAutoApproveTools + ? 'never' + : 'on-request' + : codexSettings.approvalPolicy; + const promptText = buildCombinedPrompt(options, combinedSystemPrompt); + const commandPath = executionPlan.cliPath || CODEX_COMMAND; + + // Build config overrides for max turns and reasoning effort + const overrides: Array<{ key: string; value: string | number | boolean }> = []; + if (resolvedMaxTurns !== null) { + overrides.push({ key: CONFIG_KEY_MAX_TURNS, value: resolvedMaxTurns }); + } + + // Add reasoning effort if model supports it and reasoningEffort is specified + if ( + options.reasoningEffort && + supportsReasoningEffort(options.model) && + options.reasoningEffort !== 'none' + ) { + overrides.push({ key: CODEX_REASONING_EFFORT_KEY, value: options.reasoningEffort }); + } + + const configOverrides = buildConfigOverrides(overrides); + const globalArgs = [CODEX_APPROVAL_FLAG, approvalPolicy]; + if (searchEnabled) { + globalArgs.push(CODEX_SEARCH_FLAG); + } + + // Add additional directories with write access + if (codexSettings.additionalDirs && codexSettings.additionalDirs.length > 0) { + for (const dir of codexSettings.additionalDirs) { + globalArgs.push(CODEX_ADD_DIR_FLAG, dir); + } + } + + const args = [ + ...globalArgs, + CODEX_EXEC_SUBCOMMAND, + CODEX_MODEL_FLAG, + options.model, + CODEX_JSON_FLAG, + CODEX_SANDBOX_FLAG, + resolvedSandboxMode, + ...(outputSchemaPath ? [CODEX_OUTPUT_SCHEMA_FLAG, outputSchemaPath] : []), + ...(imagePaths.length > 0 ? [CODEX_IMAGE_FLAG, imagePaths.join(',')] : []), + ...configOverrides, + promptText, + ]; + + const stream = spawnJSONLProcess({ + command: commandPath, + args, + cwd: options.cwd, + env: buildEnv(), + abortController: options.abortController, + timeout: DEFAULT_TIMEOUT_MS, + }); + + for await (const rawEvent of stream) { + const event = rawEvent as Record; + const eventType = getEventType(event); + + if (eventType === CODEX_EVENT_TYPES.error) { + const errorText = extractText(event.error ?? event.message) || 'Codex CLI error'; + + // Enhance error message with helpful context + let enhancedError = errorText; + if (errorText.toLowerCase().includes('rate limit')) { + enhancedError = `${errorText}\n\nTip: You're being rate limited. Try reducing concurrent tasks or waiting a few minutes before retrying.`; + } else if ( + errorText.toLowerCase().includes('authentication') || + errorText.toLowerCase().includes('unauthorized') + ) { + enhancedError = `${errorText}\n\nTip: Check that your OPENAI_API_KEY is set correctly or run 'codex auth login' to authenticate.`; + } else if ( + errorText.toLowerCase().includes('not found') || + errorText.toLowerCase().includes('command not found') + ) { + enhancedError = `${errorText}\n\nTip: Make sure the Codex CLI is installed. Run 'npm install -g @openai/codex-cli' to install.`; + } + + console.error('[CodexProvider] CLI error event:', { errorText, event }); + yield { type: 'error', error: enhancedError }; + continue; + } + + if (eventType === CODEX_EVENT_TYPES.threadCompleted) { + const resultText = extractText(event.result) || undefined; + yield { type: 'result', subtype: 'success', result: resultText }; + continue; + } + + if (!eventType) { + const fallbackText = extractText(event); + if (fallbackText) { + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: fallbackText }], + }, + }; + } + continue; + } + + const item = (event.item ?? {}) as Record; + const itemType = extractItemType(item); + + if ( + eventType === CODEX_EVENT_TYPES.itemStarted && + itemType === CODEX_ITEM_TYPES.commandExecution + ) { + const commandText = extractCommandText(item) || ''; + const tool = resolveCodexToolCall(commandText); + const toolUseId = toolUseTracker.register(event, item); + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: tool.name, + input: tool.input, + tool_use_id: toolUseId, + }, + ], + }, + }; + continue; + } + + if (eventType === CODEX_EVENT_TYPES.itemUpdated && itemType === CODEX_ITEM_TYPES.todoList) { + const todos = extractCodexTodoItems(item); + if (todos) { + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: getCodexTodoToolName(), + input: { todos }, + }, + ], + }, + }; + } else { + const todoText = extractText(item) || ''; + const formatted = todoText ? `Updated TODO list:\n${todoText}` : 'Updated TODO list'; + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: formatted }], + }, + }; + } + continue; + } + + if (eventType === CODEX_EVENT_TYPES.itemCompleted) { + if (itemType === CODEX_ITEM_TYPES.reasoning) { + const thinkingText = extractText(item) || ''; + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'thinking', thinking: thinkingText }], + }, + }; + continue; + } + + if (itemType === CODEX_ITEM_TYPES.commandExecution) { + const commandOutput = + extractCommandOutput(item) ?? extractCommandText(item) ?? extractText(item) ?? ''; + if (commandOutput) { + const toolUseId = toolUseTracker.resolve(event, item); + const toolResultBlock: { + type: 'tool_result'; + content: string; + tool_use_id?: string; + } = { type: 'tool_result', content: commandOutput }; + if (toolUseId) { + toolResultBlock.tool_use_id = toolUseId; + } + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [toolResultBlock], + }, + }; + } + continue; + } + + const text = extractText(item) || extractText(event); + if (text) { + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text }], + }, + }; + } + } + } + } catch (error) { + const errorInfo = classifyError(error); + const userMessage = getUserFriendlyErrorMessage(error); + const enhancedMessage = errorInfo.isRateLimit + ? `${userMessage}\n\nTip: If you're rate limited, try reducing concurrent tasks or waiting a few minutes.` + : userMessage; + + console.error('[CodexProvider] executeQuery() error:', { + type: errorInfo.type, + message: errorInfo.message, + isRateLimit: errorInfo.isRateLimit, + retryAfter: errorInfo.retryAfter, + stack: error instanceof Error ? error.stack : undefined, + }); + + yield { type: 'error', error: enhancedMessage }; + } + } + + async detectInstallation(): Promise { + const cliPath = await findCodexCliPath(); + const hasApiKey = !!process.env[OPENAI_API_KEY_ENV]; + const authIndicators = await getCodexAuthIndicators(); + const installed = !!cliPath; + + let version = ''; + if (installed) { + try { + const result = await spawnProcess({ + command: cliPath || CODEX_COMMAND, + args: [CODEX_VERSION_FLAG], + cwd: process.cwd(), + }); + version = result.stdout.trim(); + } catch { + version = ''; + } + } + + return { + installed, + path: cliPath || undefined, + version: version || undefined, + method: 'cli', + hasApiKey, + authenticated: authIndicators.hasOAuthToken || authIndicators.hasApiKey || hasApiKey, + }; + } + + getAvailableModels(): ModelDefinition[] { + // Return all available Codex/OpenAI models + return CODEX_MODELS; + } +} diff --git a/apps/server/src/providers/codex-sdk-client.ts b/apps/server/src/providers/codex-sdk-client.ts new file mode 100644 index 00000000..51f7c0d2 --- /dev/null +++ b/apps/server/src/providers/codex-sdk-client.ts @@ -0,0 +1,173 @@ +/** + * Codex SDK client - Executes Codex queries via official @openai/codex-sdk + * + * Used for programmatic control of Codex from within the application. + * Provides cleaner integration than spawning CLI processes. + */ + +import { Codex } from '@openai/codex-sdk'; +import { formatHistoryAsText, classifyError, getUserFriendlyErrorMessage } from '@automaker/utils'; +import { supportsReasoningEffort } from '@automaker/types'; +import type { ExecuteOptions, ProviderMessage } from './types.js'; + +const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY'; +const SDK_HISTORY_HEADER = 'Current request:\n'; +const DEFAULT_RESPONSE_TEXT = ''; +const SDK_ERROR_DETAILS_LABEL = 'Details:'; + +type PromptBlock = { + type: string; + text?: string; + source?: { + type?: string; + media_type?: string; + data?: string; + }; +}; + +function resolveApiKey(): string { + const apiKey = process.env[OPENAI_API_KEY_ENV]; + if (!apiKey) { + throw new Error('OPENAI_API_KEY is not set.'); + } + return apiKey; +} + +function normalizePromptBlocks(prompt: ExecuteOptions['prompt']): PromptBlock[] { + if (Array.isArray(prompt)) { + return prompt as PromptBlock[]; + } + return [{ type: 'text', text: prompt }]; +} + +function buildPromptText(options: ExecuteOptions, systemPrompt: string | null): string { + const historyText = + options.conversationHistory && options.conversationHistory.length > 0 + ? formatHistoryAsText(options.conversationHistory) + : ''; + + const promptBlocks = normalizePromptBlocks(options.prompt); + const promptTexts: string[] = []; + + for (const block of promptBlocks) { + if (block.type === 'text' && typeof block.text === 'string' && block.text.trim()) { + promptTexts.push(block.text); + } + } + + const promptContent = promptTexts.join('\n\n'); + if (!promptContent.trim()) { + throw new Error('Codex SDK prompt is empty.'); + } + + const parts: string[] = []; + if (systemPrompt) { + parts.push(`System: ${systemPrompt}`); + } + if (historyText) { + parts.push(historyText); + } + parts.push(`${SDK_HISTORY_HEADER}${promptContent}`); + + return parts.join('\n\n'); +} + +function buildSdkErrorMessage(rawMessage: string, userMessage: string): string { + if (!rawMessage) { + return userMessage; + } + if (!userMessage || rawMessage === userMessage) { + return rawMessage; + } + return `${userMessage}\n\n${SDK_ERROR_DETAILS_LABEL} ${rawMessage}`; +} + +/** + * Execute a query using the official Codex SDK + * + * The SDK provides a cleaner interface than spawning CLI processes: + * - Handles authentication automatically + * - Provides TypeScript types + * - Supports thread management and resumption + * - Better error handling + */ +export async function* executeCodexSdkQuery( + options: ExecuteOptions, + systemPrompt: string | null +): AsyncGenerator { + try { + const apiKey = resolveApiKey(); + const codex = new Codex({ apiKey }); + + // Resume existing thread or start new one + let thread; + if (options.sdkSessionId) { + try { + thread = codex.resumeThread(options.sdkSessionId); + } catch { + // If resume fails, start a new thread + thread = codex.startThread(); + } + } else { + thread = codex.startThread(); + } + + const promptText = buildPromptText(options, systemPrompt); + + // Build run options with reasoning effort if supported + const runOptions: { + signal?: AbortSignal; + reasoning?: { effort: string }; + } = { + signal: options.abortController?.signal, + }; + + // Add reasoning effort if model supports it and reasoningEffort is specified + if ( + options.reasoningEffort && + supportsReasoningEffort(options.model) && + options.reasoningEffort !== 'none' + ) { + runOptions.reasoning = { effort: options.reasoningEffort }; + } + + // Run the query + const result = await thread.run(promptText, runOptions); + + // Extract response text (from finalResponse property) + const outputText = result.finalResponse ?? DEFAULT_RESPONSE_TEXT; + + // Get thread ID (may be null if not populated yet) + const threadId = thread.id ?? undefined; + + // Yield assistant message + yield { + type: 'assistant', + session_id: threadId, + message: { + role: 'assistant', + content: [{ type: 'text', text: outputText }], + }, + }; + + // Yield result + yield { + type: 'result', + subtype: 'success', + session_id: threadId, + result: outputText, + }; + } catch (error) { + const errorInfo = classifyError(error); + const userMessage = getUserFriendlyErrorMessage(error); + const combinedMessage = buildSdkErrorMessage(errorInfo.message, userMessage); + console.error('[CodexSDK] executeQuery() error during execution:', { + type: errorInfo.type, + message: errorInfo.message, + isRateLimit: errorInfo.isRateLimit, + retryAfter: errorInfo.retryAfter, + stack: error instanceof Error ? error.stack : undefined, + }); + yield { type: 'error', error: combinedMessage }; + } +} diff --git a/apps/server/src/providers/codex-tool-mapping.ts b/apps/server/src/providers/codex-tool-mapping.ts new file mode 100644 index 00000000..2f9059a0 --- /dev/null +++ b/apps/server/src/providers/codex-tool-mapping.ts @@ -0,0 +1,385 @@ +export type CodexToolResolution = { + name: string; + input: Record; +}; + +export type CodexTodoItem = { + content: string; + status: 'pending' | 'in_progress' | 'completed'; + activeForm?: string; +}; + +const TOOL_NAME_BASH = 'Bash'; +const TOOL_NAME_READ = 'Read'; +const TOOL_NAME_EDIT = 'Edit'; +const TOOL_NAME_WRITE = 'Write'; +const TOOL_NAME_GREP = 'Grep'; +const TOOL_NAME_GLOB = 'Glob'; +const TOOL_NAME_TODO = 'TodoWrite'; + +const INPUT_KEY_COMMAND = 'command'; +const INPUT_KEY_FILE_PATH = 'file_path'; +const INPUT_KEY_PATTERN = 'pattern'; + +const SHELL_WRAPPER_PATTERNS = [ + /^\/bin\/bash\s+-lc\s+["']([\s\S]+)["']$/, + /^bash\s+-lc\s+["']([\s\S]+)["']$/, + /^\/bin\/sh\s+-lc\s+["']([\s\S]+)["']$/, + /^sh\s+-lc\s+["']([\s\S]+)["']$/, + /^cmd\.exe\s+\/c\s+["']?([\s\S]+)["']?$/i, + /^powershell(?:\.exe)?\s+-Command\s+["']?([\s\S]+)["']?$/i, + /^pwsh(?:\.exe)?\s+-Command\s+["']?([\s\S]+)["']?$/i, +] as const; + +const COMMAND_SEPARATOR_PATTERN = /\s*(?:&&|\|\||;)\s*/; +const SEGMENT_SKIP_PREFIXES = ['cd ', 'export ', 'set ', 'pushd '] as const; +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 WRITE_COMMANDS = new Set(['tee', 'touch', 'mkdir']); +const APPLY_PATCH_COMMAND = 'apply_patch'; +const APPLY_PATCH_PATTERN = /\bapply_patch\b/; +const REDIRECTION_TARGET_PATTERN = /(?:>>|>)\s*([^\s]+)/; +const SED_IN_PLACE_FLAGS = new Set(['-i', '--in-place']); +const PERL_IN_PLACE_FLAG = /-.*i/; +const SEARCH_PATTERN_FLAGS = new Set(['-e', '--regexp']); +const SEARCH_VALUE_FLAGS = new Set([ + '-g', + '--glob', + '--iglob', + '--type', + '--type-add', + '--type-clear', + '--encoding', +]); +const SEARCH_FILE_LIST_FLAGS = new Set(['--files']); +const TODO_LINE_PATTERN = /^[-*]\s*(?:\[(?[ x~])\]\s*)?(?.+)$/; +const TODO_STATUS_COMPLETED = 'completed'; +const TODO_STATUS_IN_PROGRESS = 'in_progress'; +const TODO_STATUS_PENDING = 'pending'; +const PATCH_FILE_MARKERS = [ + '*** Update File: ', + '*** Add File: ', + '*** Delete File: ', + '*** Move to: ', +] as const; + +function stripShellWrapper(command: string): string { + const trimmed = command.trim(); + for (const pattern of SHELL_WRAPPER_PATTERNS) { + const match = trimmed.match(pattern); + if (match && match[1]) { + return unescapeCommand(match[1].trim()); + } + } + return trimmed; +} + +function unescapeCommand(command: string): string { + return command.replace(/\\(["'])/g, '$1'); +} + +function extractPrimarySegment(command: string): string { + const segments = command + .split(COMMAND_SEPARATOR_PATTERN) + .map((segment) => segment.trim()) + .filter(Boolean); + + for (const segment of segments) { + const shouldSkip = SEGMENT_SKIP_PREFIXES.some((prefix) => segment.startsWith(prefix)); + if (!shouldSkip) { + return segment; + } + } + + return command.trim(); +} + +function tokenizeCommand(command: string): string[] { + const tokens: string[] = []; + let current = ''; + let inSingleQuote = false; + let inDoubleQuote = false; + let isEscaped = false; + + for (const char of command) { + if (isEscaped) { + current += char; + isEscaped = false; + continue; + } + + if (char === '\\') { + isEscaped = true; + continue; + } + + if (char === "'" && !inDoubleQuote) { + inSingleQuote = !inSingleQuote; + continue; + } + + if (char === '"' && !inSingleQuote) { + inDoubleQuote = !inDoubleQuote; + continue; + } + + if (!inSingleQuote && !inDoubleQuote && /\s/.test(char)) { + if (current) { + tokens.push(current); + current = ''; + } + continue; + } + + current += char; + } + + if (current) { + tokens.push(current); + } + + return tokens; +} + +function stripWrapperTokens(tokens: string[]): string[] { + let index = 0; + while (index < tokens.length && WRAPPER_COMMANDS.has(tokens[index].toLowerCase())) { + index += 1; + } + return tokens.slice(index); +} + +function extractFilePathFromTokens(tokens: string[]): string | null { + const candidates = tokens.slice(1).filter((token) => token && !token.startsWith('-')); + if (candidates.length === 0) return null; + return candidates[candidates.length - 1]; +} + +function extractSearchPattern(tokens: string[]): string | null { + const remaining = tokens.slice(1); + + for (let index = 0; index < remaining.length; index += 1) { + const token = remaining[index]; + if (token === '--') { + return remaining[index + 1] ?? null; + } + if (SEARCH_PATTERN_FLAGS.has(token)) { + return remaining[index + 1] ?? null; + } + if (SEARCH_VALUE_FLAGS.has(token)) { + index += 1; + continue; + } + if (token.startsWith('-')) { + continue; + } + return token; + } + + return null; +} + +function extractTeeTarget(tokens: string[]): string | null { + const teeIndex = tokens.findIndex((token) => token === 'tee'); + if (teeIndex < 0) return null; + const candidate = tokens[teeIndex + 1]; + return candidate && !candidate.startsWith('-') ? candidate : null; +} + +function extractRedirectionTarget(command: string): string | null { + const match = command.match(REDIRECTION_TARGET_PATTERN); + return match?.[1] ?? null; +} + +function hasSedInPlaceFlag(tokens: string[]): boolean { + return tokens.some((token) => SED_IN_PLACE_FLAGS.has(token) || token.startsWith('-i')); +} + +function hasPerlInPlaceFlag(tokens: string[]): boolean { + return tokens.some((token) => PERL_IN_PLACE_FLAG.test(token)); +} + +function extractPatchFilePath(command: string): string | null { + for (const marker of PATCH_FILE_MARKERS) { + const index = command.indexOf(marker); + if (index < 0) continue; + const start = index + marker.length; + const end = command.indexOf('\n', start); + const rawPath = (end === -1 ? command.slice(start) : command.slice(start, end)).trim(); + if (rawPath) return rawPath; + } + return null; +} + +function buildInputWithFilePath(filePath: string | null): Record { + return filePath ? { [INPUT_KEY_FILE_PATH]: filePath } : {}; +} + +function buildInputWithPattern(pattern: string | null): Record { + return pattern ? { [INPUT_KEY_PATTERN]: pattern } : {}; +} + +export function resolveCodexToolCall(command: string): CodexToolResolution { + const normalized = stripShellWrapper(command); + const primarySegment = extractPrimarySegment(normalized); + const tokens = stripWrapperTokens(tokenizeCommand(primarySegment)); + const commandToken = tokens[0]?.toLowerCase() ?? ''; + + const redirectionTarget = extractRedirectionTarget(primarySegment); + if (redirectionTarget) { + return { + name: TOOL_NAME_WRITE, + input: buildInputWithFilePath(redirectionTarget), + }; + } + + if (commandToken === APPLY_PATCH_COMMAND || APPLY_PATCH_PATTERN.test(primarySegment)) { + return { + name: TOOL_NAME_EDIT, + input: buildInputWithFilePath(extractPatchFilePath(primarySegment)), + }; + } + + if (commandToken === 'sed' && hasSedInPlaceFlag(tokens)) { + return { + name: TOOL_NAME_EDIT, + input: buildInputWithFilePath(extractFilePathFromTokens(tokens)), + }; + } + + if (commandToken === 'perl' && hasPerlInPlaceFlag(tokens)) { + return { + name: TOOL_NAME_EDIT, + input: buildInputWithFilePath(extractFilePathFromTokens(tokens)), + }; + } + + if (WRITE_COMMANDS.has(commandToken)) { + const filePath = + commandToken === 'tee' ? extractTeeTarget(tokens) : extractFilePathFromTokens(tokens); + return { + name: TOOL_NAME_WRITE, + input: buildInputWithFilePath(filePath), + }; + } + + if (SEARCH_COMMANDS.has(commandToken)) { + if (tokens.some((token) => SEARCH_FILE_LIST_FLAGS.has(token))) { + return { + name: TOOL_NAME_GLOB, + input: buildInputWithPattern(extractFilePathFromTokens(tokens)), + }; + } + + return { + name: TOOL_NAME_GREP, + input: buildInputWithPattern(extractSearchPattern(tokens)), + }; + } + + if (GLOB_COMMANDS.has(commandToken)) { + return { + name: TOOL_NAME_GLOB, + input: buildInputWithPattern(extractFilePathFromTokens(tokens)), + }; + } + + if (READ_COMMANDS.has(commandToken)) { + return { + name: TOOL_NAME_READ, + input: buildInputWithFilePath(extractFilePathFromTokens(tokens)), + }; + } + + return { + name: TOOL_NAME_BASH, + input: { [INPUT_KEY_COMMAND]: normalized }, + }; +} + +function parseTodoLines(lines: string[]): CodexTodoItem[] { + const todos: CodexTodoItem[] = []; + + for (const line of lines) { + const match = line.match(TODO_LINE_PATTERN); + if (!match?.groups?.content) continue; + + const statusToken = match.groups.status; + const status = + statusToken === 'x' + ? TODO_STATUS_COMPLETED + : statusToken === '~' + ? TODO_STATUS_IN_PROGRESS + : TODO_STATUS_PENDING; + + todos.push({ content: match.groups.content.trim(), status }); + } + + return todos; +} + +function extractTodoFromArray(value: unknown[]): CodexTodoItem[] { + return value + .map((entry) => { + if (typeof entry === 'string') { + return { content: entry, status: TODO_STATUS_PENDING }; + } + if (entry && typeof entry === 'object') { + const record = entry as Record; + const content = + typeof record.content === 'string' + ? record.content + : typeof record.text === 'string' + ? record.text + : typeof record.title === 'string' + ? record.title + : null; + if (!content) return null; + const status = + record.status === TODO_STATUS_COMPLETED || + record.status === TODO_STATUS_IN_PROGRESS || + record.status === TODO_STATUS_PENDING + ? (record.status as CodexTodoItem['status']) + : TODO_STATUS_PENDING; + const activeForm = typeof record.activeForm === 'string' ? record.activeForm : undefined; + return { content, status, activeForm }; + } + return null; + }) + .filter((item): item is CodexTodoItem => Boolean(item)); +} + +export function extractCodexTodoItems(item: Record): CodexTodoItem[] | null { + const todosValue = item.todos; + if (Array.isArray(todosValue)) { + const todos = extractTodoFromArray(todosValue); + return todos.length > 0 ? todos : null; + } + + const itemsValue = item.items; + if (Array.isArray(itemsValue)) { + const todos = extractTodoFromArray(itemsValue); + return todos.length > 0 ? todos : null; + } + + const textValue = + typeof item.text === 'string' + ? item.text + : typeof item.content === 'string' + ? item.content + : null; + if (!textValue) return null; + + const lines = textValue + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + const todos = parseTodoLines(lines); + return todos.length > 0 ? todos : null; +} + +export function getCodexTodoToolName(): string { + return TOOL_NAME_TODO; +} diff --git a/apps/server/src/providers/cursor-provider.ts b/apps/server/src/providers/cursor-provider.ts index ca708874..c26cd4a4 100644 --- a/apps/server/src/providers/cursor-provider.ts +++ b/apps/server/src/providers/cursor-provider.ts @@ -321,12 +321,19 @@ export class CursorProvider extends CliProvider { // Build CLI arguments for cursor-agent // NOTE: Prompt is NOT included here - it's passed via stdin to avoid // shell escaping issues when content contains $(), backticks, etc. - const cliArgs: string[] = [ + const cliArgs: string[] = []; + + // If using Cursor IDE (cliPath is 'cursor' not 'cursor-agent'), add 'agent' subcommand + if (this.cliPath && !this.cliPath.includes('cursor-agent')) { + cliArgs.push('agent'); + } + + cliArgs.push( '-p', // Print mode (non-interactive) '--output-format', 'stream-json', - '--stream-partial-output', // Real-time streaming - ]; + '--stream-partial-output' // Real-time streaming + ); // Only add --force if NOT in read-only mode // Without --force, Cursor CLI suggests changes but doesn't apply them @@ -472,7 +479,9 @@ export class CursorProvider extends CliProvider { // ========================================================================== /** - * Override CLI detection to add Cursor-specific versions directory check + * Override CLI detection to add Cursor-specific checks: + * 1. Versions directory for cursor-agent installations + * 2. Cursor IDE with 'cursor agent' subcommand support */ protected detectCli(): CliDetectionResult { // First try standard detection (PATH, common paths, WSL) @@ -507,6 +516,39 @@ export class CursorProvider extends CliProvider { } } + // If cursor-agent not found, try to find 'cursor' IDE and use 'cursor agent' subcommand + // The Cursor IDE includes the agent as a subcommand: cursor agent + if (process.platform !== 'win32') { + const cursorPaths = [ + '/usr/bin/cursor', + '/usr/local/bin/cursor', + path.join(os.homedir(), '.local/bin/cursor'), + '/opt/cursor/cursor', + ]; + + for (const cursorPath of cursorPaths) { + if (fs.existsSync(cursorPath)) { + // Verify cursor agent subcommand works + try { + execSync(`"${cursorPath}" agent --version`, { + encoding: 'utf8', + timeout: 5000, + stdio: 'pipe', + }); + logger.debug(`Using cursor agent via Cursor IDE: ${cursorPath}`); + // Return cursor path but we'll use 'cursor agent' subcommand + return { + cliPath: cursorPath, + useWsl: false, + strategy: 'native', + }; + } catch { + // cursor agent subcommand doesn't work, try next path + } + } + } + } + return result; } @@ -838,9 +880,16 @@ export class CursorProvider extends CliProvider { }); return result; } - const result = execSync(`"${this.cliPath}" --version`, { + + // If using Cursor IDE, use 'cursor agent --version' + const versionCmd = this.cliPath.includes('cursor-agent') + ? `"${this.cliPath}" --version` + : `"${this.cliPath}" agent --version`; + + const result = execSync(versionCmd, { encoding: 'utf8', timeout: 5000, + stdio: 'pipe', }).trim(); return result; } catch { diff --git a/apps/server/src/providers/provider-factory.ts b/apps/server/src/providers/provider-factory.ts index 25eb7bd0..0ebb6b5f 100644 --- a/apps/server/src/providers/provider-factory.ts +++ b/apps/server/src/providers/provider-factory.ts @@ -7,7 +7,7 @@ import { BaseProvider } from './base-provider.js'; import type { InstallationStatus, ModelDefinition } from './types.js'; -import { isCursorModel, type ModelProvider } from '@automaker/types'; +import { isCursorModel, isCodexModel, type ModelProvider } from '@automaker/types'; /** * Provider registration entry @@ -165,6 +165,7 @@ export class ProviderFactory { // Import providers for registration side-effects import { ClaudeProvider } from './claude-provider.js'; import { CursorProvider } from './cursor-provider.js'; +import { CodexProvider } from './codex-provider.js'; // Register Claude provider registerProvider('claude', { @@ -184,3 +185,11 @@ registerProvider('cursor', { canHandleModel: (model: string) => isCursorModel(model), priority: 10, // Higher priority - check Cursor models first }); + +// Register Codex provider +registerProvider('codex', { + factory: () => new CodexProvider(), + aliases: ['openai'], + canHandleModel: (model: string) => isCodexModel(model), + priority: 5, // Medium priority - check after Cursor but before Claude +}); diff --git a/apps/server/src/routes/setup/index.ts b/apps/server/src/routes/setup/index.ts index 6c9f42a2..3fac6a20 100644 --- a/apps/server/src/routes/setup/index.ts +++ b/apps/server/src/routes/setup/index.ts @@ -11,8 +11,12 @@ import { createDeleteApiKeyHandler } from './routes/delete-api-key.js'; import { createApiKeysHandler } from './routes/api-keys.js'; import { createPlatformHandler } from './routes/platform.js'; import { createVerifyClaudeAuthHandler } from './routes/verify-claude-auth.js'; +import { createVerifyCodexAuthHandler } from './routes/verify-codex-auth.js'; import { createGhStatusHandler } from './routes/gh-status.js'; import { createCursorStatusHandler } from './routes/cursor-status.js'; +import { createCodexStatusHandler } from './routes/codex-status.js'; +import { createInstallCodexHandler } from './routes/install-codex.js'; +import { createAuthCodexHandler } from './routes/auth-codex.js'; import { createGetCursorConfigHandler, createSetCursorDefaultModelHandler, @@ -35,10 +39,16 @@ export function createSetupRoutes(): Router { router.get('/api-keys', createApiKeysHandler()); router.get('/platform', createPlatformHandler()); router.post('/verify-claude-auth', createVerifyClaudeAuthHandler()); + router.post('/verify-codex-auth', createVerifyCodexAuthHandler()); router.get('/gh-status', createGhStatusHandler()); // Cursor CLI routes router.get('/cursor-status', createCursorStatusHandler()); + + // Codex CLI routes + router.get('/codex-status', createCodexStatusHandler()); + router.post('/install-codex', createInstallCodexHandler()); + router.post('/auth-codex', createAuthCodexHandler()); router.get('/cursor-config', createGetCursorConfigHandler()); router.post('/cursor-config/default-model', createSetCursorDefaultModelHandler()); router.post('/cursor-config/models', createSetCursorModelsHandler()); diff --git a/apps/server/src/routes/setup/routes/auth-codex.ts b/apps/server/src/routes/setup/routes/auth-codex.ts new file mode 100644 index 00000000..c58414d7 --- /dev/null +++ b/apps/server/src/routes/setup/routes/auth-codex.ts @@ -0,0 +1,31 @@ +/** + * POST /auth-codex endpoint - Authenticate Codex CLI + */ + +import type { Request, Response } from 'express'; +import { logError, getErrorMessage } from '../common.js'; + +/** + * Creates handler for POST /api/setup/auth-codex + * Returns instructions for manual Codex CLI authentication + */ +export function createAuthCodexHandler() { + return async (_req: Request, res: Response): Promise => { + try { + const loginCommand = 'codex login'; + + res.json({ + success: true, + requiresManualAuth: true, + command: loginCommand, + message: `Please authenticate Codex CLI manually by running: ${loginCommand}`, + }); + } catch (error) { + logError(error, 'Auth Codex failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/src/routes/setup/routes/codex-status.ts b/apps/server/src/routes/setup/routes/codex-status.ts new file mode 100644 index 00000000..fee782da --- /dev/null +++ b/apps/server/src/routes/setup/routes/codex-status.ts @@ -0,0 +1,43 @@ +/** + * GET /codex-status endpoint - Get Codex CLI installation and auth status + */ + +import type { Request, Response } from 'express'; +import { CodexProvider } from '../../../providers/codex-provider.js'; +import { getErrorMessage, logError } from '../common.js'; + +/** + * Creates handler for GET /api/setup/codex-status + * Returns Codex CLI installation and authentication status + */ +export function createCodexStatusHandler() { + const installCommand = 'npm install -g @openai/codex'; + const loginCommand = 'codex login'; + + return async (_req: Request, res: Response): Promise => { + try { + const provider = new CodexProvider(); + const status = await provider.detectInstallation(); + + res.json({ + success: true, + installed: status.installed, + version: status.version || null, + path: status.path || null, + auth: { + authenticated: status.authenticated || false, + method: status.method || 'cli', + hasApiKey: status.hasApiKey || false, + }, + installCommand, + loginCommand, + }); + } catch (error) { + logError(error, 'Get Codex status failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/src/routes/setup/routes/install-codex.ts b/apps/server/src/routes/setup/routes/install-codex.ts new file mode 100644 index 00000000..ea40e92d --- /dev/null +++ b/apps/server/src/routes/setup/routes/install-codex.ts @@ -0,0 +1,33 @@ +/** + * POST /install-codex endpoint - Install Codex CLI + */ + +import type { Request, Response } from 'express'; +import { logError, getErrorMessage } from '../common.js'; + +/** + * Creates handler for POST /api/setup/install-codex + * Installs Codex CLI (currently returns instructions for manual install) + */ +export function createInstallCodexHandler() { + return async (_req: Request, res: Response): Promise => { + try { + // For now, return manual installation instructions + // In the future, this could potentially trigger npm global install + const installCommand = 'npm install -g @openai/codex'; + + res.json({ + success: true, + message: `Please install Codex CLI manually by running: ${installCommand}`, + requiresManualInstall: true, + installCommand, + }); + } catch (error) { + logError(error, 'Install Codex failed'); + res.status(500).json({ + success: false, + error: getErrorMessage(error), + }); + } + }; +} diff --git a/apps/server/src/routes/setup/routes/verify-codex-auth.ts b/apps/server/src/routes/setup/routes/verify-codex-auth.ts new file mode 100644 index 00000000..3580ffd9 --- /dev/null +++ b/apps/server/src/routes/setup/routes/verify-codex-auth.ts @@ -0,0 +1,232 @@ +/** + * POST /verify-codex-auth endpoint - Verify Codex authentication + */ + +import type { Request, Response } from 'express'; +import { createLogger } from '@automaker/utils'; +import { CODEX_MODEL_MAP } from '@automaker/types'; +import { ProviderFactory } from '../../../providers/provider-factory.js'; +import { getApiKey } from '../common.js'; +import { getCodexAuthIndicators } from '@automaker/platform'; + +const logger = createLogger('Setup'); +const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY'; +const AUTH_PROMPT = "Reply with only the word 'ok'"; +const AUTH_TIMEOUT_MS = 30000; +const ERROR_BILLING_MESSAGE = + 'Credit balance is too low. Please add credits to your OpenAI account.'; +const ERROR_RATE_LIMIT_MESSAGE = + 'Rate limit reached. Please wait a while before trying again or upgrade your plan.'; +const ERROR_CLI_AUTH_REQUIRED = + "CLI authentication failed. Please run 'codex login' to authenticate."; +const ERROR_API_KEY_REQUIRED = 'No API key configured. Please enter an API key first.'; +const AUTH_ERROR_PATTERNS = [ + 'authentication', + 'unauthorized', + 'invalid_api_key', + 'invalid api key', + 'api key is invalid', + 'not authenticated', + 'login', + 'auth(', + 'token refresh', + 'tokenrefresh', + 'failed to parse server response', + 'transport channel closed', +]; +const BILLING_ERROR_PATTERNS = [ + 'credit balance is too low', + 'credit balance too low', + 'insufficient credits', + 'insufficient balance', + 'no credits', + 'out of credits', + 'billing', + 'payment required', + 'add credits', +]; +const RATE_LIMIT_PATTERNS = [ + 'limit reached', + 'rate limit', + 'rate_limit', + 'too many requests', + 'resets', + '429', +]; + +function containsAuthError(text: string): boolean { + const lowerText = text.toLowerCase(); + return AUTH_ERROR_PATTERNS.some((pattern) => lowerText.includes(pattern)); +} + +function isBillingError(text: string): boolean { + const lowerText = text.toLowerCase(); + return BILLING_ERROR_PATTERNS.some((pattern) => lowerText.includes(pattern)); +} + +function isRateLimitError(text: string): boolean { + if (isBillingError(text)) { + return false; + } + const lowerText = text.toLowerCase(); + return RATE_LIMIT_PATTERNS.some((pattern) => lowerText.includes(pattern)); +} + +export function createVerifyCodexAuthHandler() { + return async (req: Request, res: Response): Promise => { + const { authMethod } = req.body as { authMethod?: 'cli' | 'api_key' }; + const abortController = new AbortController(); + const timeoutId = setTimeout(() => abortController.abort(), AUTH_TIMEOUT_MS); + + const originalKey = process.env[OPENAI_API_KEY_ENV]; + + try { + if (authMethod === 'cli') { + delete process.env[OPENAI_API_KEY_ENV]; + } else if (authMethod === 'api_key') { + const storedApiKey = getApiKey('openai'); + if (storedApiKey) { + process.env[OPENAI_API_KEY_ENV] = storedApiKey; + } else if (!process.env[OPENAI_API_KEY_ENV]) { + res.json({ success: true, authenticated: false, error: ERROR_API_KEY_REQUIRED }); + return; + } + } + + if (authMethod === 'cli') { + const authIndicators = await getCodexAuthIndicators(); + if (!authIndicators.hasOAuthToken && !authIndicators.hasApiKey) { + res.json({ + success: true, + authenticated: false, + error: ERROR_CLI_AUTH_REQUIRED, + }); + return; + } + } + + // Use Codex provider explicitly (not ProviderFactory.getProviderForModel) + // because Cursor also supports GPT models and has higher priority + const provider = ProviderFactory.getProviderByName('codex'); + if (!provider) { + throw new Error('Codex provider not available'); + } + const stream = provider.executeQuery({ + prompt: AUTH_PROMPT, + model: CODEX_MODEL_MAP.gpt52Codex, + cwd: process.cwd(), + maxTurns: 1, + allowedTools: [], + abortController, + }); + + let receivedAnyContent = false; + let errorMessage = ''; + + for await (const msg of stream) { + if (msg.type === 'error' && msg.error) { + if (isBillingError(msg.error)) { + errorMessage = ERROR_BILLING_MESSAGE; + } else if (isRateLimitError(msg.error)) { + errorMessage = ERROR_RATE_LIMIT_MESSAGE; + } else { + errorMessage = msg.error; + } + break; + } + + if (msg.type === 'assistant' && msg.message?.content) { + for (const block of msg.message.content) { + if (block.type === 'text' && block.text) { + receivedAnyContent = true; + if (isBillingError(block.text)) { + errorMessage = ERROR_BILLING_MESSAGE; + break; + } + if (isRateLimitError(block.text)) { + errorMessage = ERROR_RATE_LIMIT_MESSAGE; + break; + } + if (containsAuthError(block.text)) { + errorMessage = block.text; + break; + } + } + } + } + + if (msg.type === 'result' && msg.result) { + receivedAnyContent = true; + if (isBillingError(msg.result)) { + errorMessage = ERROR_BILLING_MESSAGE; + } else if (isRateLimitError(msg.result)) { + errorMessage = ERROR_RATE_LIMIT_MESSAGE; + } else if (containsAuthError(msg.result)) { + errorMessage = msg.result; + break; + } + } + } + + if (errorMessage) { + // Rate limit and billing errors mean auth succeeded but usage is limited + const isUsageLimitError = + errorMessage === ERROR_BILLING_MESSAGE || errorMessage === ERROR_RATE_LIMIT_MESSAGE; + + const response: { + success: boolean; + authenticated: boolean; + error: string; + details?: string; + } = { + success: true, + authenticated: isUsageLimitError ? true : false, + error: isUsageLimitError + ? errorMessage + : authMethod === 'cli' + ? ERROR_CLI_AUTH_REQUIRED + : 'API key is invalid or has been revoked.', + }; + + // Include detailed error for auth failures so users can debug + if (!isUsageLimitError && errorMessage !== response.error) { + response.details = errorMessage; + } + + res.json(response); + return; + } + + if (!receivedAnyContent) { + res.json({ + success: true, + authenticated: false, + error: 'No response received from Codex. Please check your authentication.', + }); + return; + } + + res.json({ success: true, authenticated: true }); + } catch (error: unknown) { + const errMessage = error instanceof Error ? error.message : String(error); + logger.error('[Setup] Codex auth verification error:', errMessage); + const normalizedError = isBillingError(errMessage) + ? ERROR_BILLING_MESSAGE + : isRateLimitError(errMessage) + ? ERROR_RATE_LIMIT_MESSAGE + : errMessage; + res.json({ + success: true, + authenticated: false, + error: normalizedError, + }); + } finally { + clearTimeout(timeoutId); + if (originalKey !== undefined) { + process.env[OPENAI_API_KEY_ENV] = originalKey; + } else { + delete process.env[OPENAI_API_KEY_ENV]; + } + } + }; +} diff --git a/apps/server/tests/unit/providers/codex-provider.test.ts b/apps/server/tests/unit/providers/codex-provider.test.ts new file mode 100644 index 00000000..54b011a2 --- /dev/null +++ b/apps/server/tests/unit/providers/codex-provider.test.ts @@ -0,0 +1,290 @@ +import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest'; +import os from 'os'; +import path from 'path'; +import { CodexProvider } from '@/providers/codex-provider.js'; +import { collectAsyncGenerator } from '../../utils/helpers.js'; +import { + spawnJSONLProcess, + findCodexCliPath, + secureFs, + getCodexConfigDir, + getCodexAuthIndicators, +} from '@automaker/platform'; + +const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY'; +const openaiCreateMock = vi.fn(); +const originalOpenAIKey = process.env[OPENAI_API_KEY_ENV]; + +vi.mock('openai', () => ({ + default: class { + responses = { create: openaiCreateMock }; + }, +})); + +const EXEC_SUBCOMMAND = 'exec'; + +vi.mock('@automaker/platform', () => ({ + spawnJSONLProcess: vi.fn(), + spawnProcess: vi.fn(), + findCodexCliPath: vi.fn(), + getCodexAuthIndicators: vi.fn().mockResolvedValue({ + hasOAuthToken: false, + hasApiKey: false, + }), + getCodexConfigDir: vi.fn().mockReturnValue('/home/test/.codex'), + secureFs: { + readFile: vi.fn(), + mkdir: vi.fn(), + writeFile: vi.fn(), + }, + getDataDirectory: vi.fn(), +})); + +vi.mock('@/services/settings-service.js', () => ({ + SettingsService: class { + async getGlobalSettings() { + return { + codexAutoLoadAgents: false, + codexSandboxMode: 'workspace-write', + codexApprovalPolicy: 'on-request', + }; + } + }, +})); + +describe('codex-provider.ts', () => { + let provider: CodexProvider; + + afterAll(() => { + if (originalOpenAIKey !== undefined) { + process.env[OPENAI_API_KEY_ENV] = originalOpenAIKey; + } else { + delete process.env[OPENAI_API_KEY_ENV]; + } + }); + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getCodexConfigDir).mockReturnValue('/home/test/.codex'); + vi.mocked(findCodexCliPath).mockResolvedValue('/usr/bin/codex'); + vi.mocked(getCodexAuthIndicators).mockResolvedValue({ + hasOAuthToken: true, + hasApiKey: false, + }); + delete process.env[OPENAI_API_KEY_ENV]; + provider = new CodexProvider(); + }); + + describe('executeQuery', () => { + it('emits tool_use and tool_result with shared tool_use_id for command execution', async () => { + const mockEvents = [ + { + type: 'item.started', + item: { + type: 'command_execution', + id: 'cmd-1', + command: 'ls', + }, + }, + { + type: 'item.completed', + item: { + type: 'command_execution', + id: 'cmd-1', + output: 'file1\nfile2', + }, + }, + ]; + + vi.mocked(spawnJSONLProcess).mockReturnValue( + (async function* () { + for (const event of mockEvents) { + yield event; + } + })() + ); + const results = await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'List files', + model: 'gpt-5.2', + cwd: '/tmp', + }) + ); + + expect(results).toHaveLength(2); + const toolUse = results[0]; + const toolResult = results[1]; + + expect(toolUse.type).toBe('assistant'); + expect(toolUse.message?.content[0].type).toBe('tool_use'); + const toolUseId = toolUse.message?.content[0].tool_use_id; + expect(toolUseId).toBeDefined(); + + expect(toolResult.type).toBe('assistant'); + expect(toolResult.message?.content[0].type).toBe('tool_result'); + expect(toolResult.message?.content[0].tool_use_id).toBe(toolUseId); + expect(toolResult.message?.content[0].content).toBe('file1\nfile2'); + }); + + it('adds output schema and max turn overrides when configured', async () => { + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + const schema = { type: 'object', properties: { ok: { type: 'string' } } }; + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Return JSON', + model: 'gpt-5.2', + cwd: '/tmp', + maxTurns: 5, + allowedTools: ['Read'], + outputFormat: { type: 'json_schema', schema }, + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + expect(call.args).toContain('--output-schema'); + const schemaIndex = call.args.indexOf('--output-schema'); + const schemaPath = call.args[schemaIndex + 1]; + expect(schemaPath).toBe(path.join('/tmp', '.codex', 'output-schema.json')); + expect(secureFs.writeFile).toHaveBeenCalledWith( + schemaPath, + JSON.stringify(schema, null, 2), + 'utf-8' + ); + expect(call.args).toContain('--config'); + expect(call.args).toContain('max_turns=5'); + expect(call.args).not.toContain('--search'); + }); + + it('overrides approval policy when MCP auto-approval is enabled', async () => { + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Test approvals', + model: 'gpt-5.2', + cwd: '/tmp', + mcpServers: { mock: { type: 'stdio', command: 'node' } }, + mcpAutoApproveTools: true, + codexSettings: { approvalPolicy: 'untrusted' }, + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + const approvalIndex = call.args.indexOf('--ask-for-approval'); + const execIndex = call.args.indexOf(EXEC_SUBCOMMAND); + const searchIndex = call.args.indexOf('--search'); + expect(call.args[approvalIndex + 1]).toBe('never'); + expect(approvalIndex).toBeGreaterThan(-1); + expect(execIndex).toBeGreaterThan(-1); + expect(approvalIndex).toBeLessThan(execIndex); + expect(searchIndex).toBeGreaterThan(-1); + expect(searchIndex).toBeLessThan(execIndex); + }); + + it('injects user and project instructions when auto-load is enabled', async () => { + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + const userPath = path.join('/home/test/.codex', 'AGENTS.md'); + const projectPath = path.join('/tmp/project', '.codex', 'AGENTS.md'); + vi.mocked(secureFs.readFile).mockImplementation(async (filePath: string) => { + if (filePath === userPath) { + return 'User rules'; + } + if (filePath === projectPath) { + return 'Project rules'; + } + throw new Error('missing'); + }); + + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Hello', + model: 'gpt-5.2', + cwd: '/tmp/project', + codexSettings: { autoLoadAgents: true }, + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + const promptText = call.args[call.args.length - 1]; + expect(promptText).toContain('User rules'); + expect(promptText).toContain('Project rules'); + }); + + it('disables sandbox mode when running in cloud storage paths', async () => { + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + const cloudPath = path.join(os.homedir(), 'Dropbox', 'project'); + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Hello', + model: 'gpt-5.2', + cwd: cloudPath, + codexSettings: { sandboxMode: 'workspace-write' }, + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + const sandboxIndex = call.args.indexOf('--sandbox'); + expect(call.args[sandboxIndex + 1]).toBe('danger-full-access'); + }); + + it('uses the SDK when no tools are requested and an API key is present', async () => { + process.env[OPENAI_API_KEY_ENV] = 'sk-test'; + openaiCreateMock.mockResolvedValue({ + id: 'resp-123', + output_text: 'Hello from SDK', + error: null, + }); + + const results = await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Hello', + model: 'gpt-5.2', + cwd: '/tmp', + allowedTools: [], + }) + ); + + expect(openaiCreateMock).toHaveBeenCalled(); + const request = openaiCreateMock.mock.calls[0][0]; + expect(request.tool_choice).toBe('none'); + expect(results[0].message?.content[0].text).toBe('Hello from SDK'); + expect(results[1].result).toBe('Hello from SDK'); + }); + + it('uses the CLI when tools are requested even if an API key is present', async () => { + process.env[OPENAI_API_KEY_ENV] = 'sk-test'; + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Read files', + model: 'gpt-5.2', + cwd: '/tmp', + allowedTools: ['Read'], + }) + ); + + expect(openaiCreateMock).not.toHaveBeenCalled(); + expect(spawnJSONLProcess).toHaveBeenCalled(); + }); + + it('falls back to CLI when no tools are requested and no API key is available', async () => { + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Hello', + model: 'gpt-5.2', + cwd: '/tmp', + allowedTools: [], + }) + ); + + expect(openaiCreateMock).not.toHaveBeenCalled(); + expect(spawnJSONLProcess).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/server/tests/unit/providers/provider-factory.test.ts b/apps/server/tests/unit/providers/provider-factory.test.ts index eb37d83a..b9e44751 100644 --- a/apps/server/tests/unit/providers/provider-factory.test.ts +++ b/apps/server/tests/unit/providers/provider-factory.test.ts @@ -141,9 +141,9 @@ describe('provider-factory.ts', () => { expect(hasClaudeProvider).toBe(true); }); - it('should return exactly 2 providers', () => { + it('should return exactly 3 providers', () => { const providers = ProviderFactory.getAllProviders(); - expect(providers).toHaveLength(2); + expect(providers).toHaveLength(3); }); it('should include CursorProvider', () => { @@ -179,7 +179,8 @@ describe('provider-factory.ts', () => { expect(keys).toContain('claude'); expect(keys).toContain('cursor'); - expect(keys).toHaveLength(2); + expect(keys).toContain('codex'); + expect(keys).toHaveLength(3); }); it('should include cursor status', async () => { diff --git a/apps/ui/src/components/ui/provider-icon.tsx b/apps/ui/src/components/ui/provider-icon.tsx new file mode 100644 index 00000000..e0996a68 --- /dev/null +++ b/apps/ui/src/components/ui/provider-icon.tsx @@ -0,0 +1,154 @@ +import type { ComponentType, SVGProps } from 'react'; +import { cn } from '@/lib/utils'; +import type { AgentModel, ModelProvider } from '@automaker/types'; +import { getProviderFromModel } from '@/lib/utils'; + +const PROVIDER_ICON_KEYS = { + anthropic: 'anthropic', + openai: 'openai', + cursor: 'cursor', + gemini: 'gemini', + grok: 'grok', +} as const; + +type ProviderIconKey = keyof typeof PROVIDER_ICON_KEYS; + +interface ProviderIconDefinition { + viewBox: string; + path: string; +} + +const PROVIDER_ICON_DEFINITIONS: Record = { + anthropic: { + viewBox: '0 0 24 24', + path: 'M17.3041 3.541h-3.6718l6.696 16.918H24Zm-10.6082 0L0 20.459h3.7442l1.3693-3.5527h7.0052l1.3693 3.5528h3.7442L10.5363 3.5409Zm-.3712 10.2232 2.2914-5.9456 2.2914 5.9456Z', + }, + openai: { + viewBox: '0 0 158.7128 157.296', + path: 'M60.8734,57.2556v-14.9432c0-1.2586.4722-2.2029,1.5728-2.8314l30.0443-17.3023c4.0899-2.3593,8.9662-3.4599,13.9988-3.4599,18.8759,0,30.8307,14.6289,30.8307,30.2006,0,1.1007,0,2.3593-.158,3.6178l-31.1446-18.2467c-1.8872-1.1006-3.7754-1.1006-5.6629,0l-39.4812,22.9651ZM131.0276,115.4561v-35.7074c0-2.2028-.9446-3.7756-2.8318-4.8763l-39.481-22.9651,12.8982-7.3934c1.1007-.6285,2.0453-.6285,3.1458,0l30.0441,17.3024c8.6523,5.0341,14.4708,15.7296,14.4708,26.1107,0,11.9539-7.0769,22.965-18.2461,27.527v.0021ZM51.593,83.9964l-12.8982-7.5497c-1.1007-.6285-1.5728-1.5728-1.5728-2.8314v-34.6048c0-16.8303,12.8982-29.5722,30.3585-29.5722,6.607,0,12.7403,2.2029,17.9324,6.1349l-30.987,17.9324c-1.8871,1.1007-2.8314,2.6735-2.8314,4.8764v45.6159l-.0014-.0015ZM79.3562,100.0403l-18.4829-10.3811v-22.0209l18.4829-10.3811,18.4812,10.3811v22.0209l-18.4812,10.3811ZM91.2319,147.8591c-6.607,0-12.7403-2.2031-17.9324-6.1344l30.9866-17.9333c1.8872-1.1005,2.8318-2.6728,2.8318-4.8759v-45.616l13.0564,7.5498c1.1005.6285,1.5723,1.5728,1.5723,2.8314v34.6051c0,16.8297-13.0564,29.5723-30.5147,29.5723v.001ZM53.9522,112.7822l-30.0443-17.3024c-8.652-5.0343-14.471-15.7296-14.471-26.1107,0-12.1119,7.2356-22.9652,18.403-27.5272v35.8634c0,2.2028.9443,3.7756,2.8314,4.8763l39.3248,22.8068-12.8982,7.3938c-1.1007.6287-2.045.6287-3.1456,0ZM52.2229,138.5791c-17.7745,0-30.8306-13.3713-30.8306-29.8871,0-1.2585.1578-2.5169.3143-3.7754l30.987,17.9323c1.8871,1.1005,3.7757,1.1005,5.6628,0l39.4811-22.807v14.9435c0,1.2585-.4721,2.2021-1.5728,2.8308l-30.0443,17.3025c-4.0898,2.359-8.9662,3.4605-13.9989,3.4605h.0014ZM91.2319,157.296c19.0327,0,34.9188-13.5272,38.5383-31.4594,17.6164-4.562,28.9425-21.0779,28.9425-37.908,0-11.0112-4.719-21.7066-13.2133-29.4143.7867-3.3035,1.2595-6.607,1.2595-9.909,0-22.4929-18.2471-39.3247-39.3251-39.3247-4.2461,0-8.3363.6285-12.4262,2.045-7.0792-6.9213-16.8318-11.3254-27.5271-11.3254-19.0331,0-34.9191,13.5268-38.5384,31.4591C11.3255,36.0212,0,52.5373,0,69.3675c0,11.0112,4.7184,21.7065,13.2125,29.4142-.7865,3.3035-1.2586,6.6067-1.2586,9.9092,0,22.4923,18.2466,39.3241,39.3248,39.3241,4.2462,0,8.3362-.6277,12.426-2.0441,7.0776,6.921,16.8302,11.3251,27.5271,11.3251Z', + }, + cursor: { + viewBox: '0 0 512 512', + // Official Cursor logo - hexagonal shape with triangular wedge + path: 'M415.035 156.35l-151.503-87.4695c-4.865-2.8094-10.868-2.8094-15.733 0l-151.4969 87.4695c-4.0897 2.362-6.6146 6.729-6.6146 11.459v176.383c0 4.73 2.5249 9.097 6.6146 11.458l151.5039 87.47c4.865 2.809 10.868 2.809 15.733 0l151.504-87.47c4.089-2.361 6.614-6.728 6.614-11.458v-176.383c0-4.73-2.525-9.097-6.614-11.459zm-9.516 18.528l-146.255 253.32c-.988 1.707-3.599 1.01-3.599-.967v-165.872c0-3.314-1.771-6.379-4.644-8.044l-143.645-82.932c-1.707-.988-1.01-3.599.968-3.599h292.509c4.154 0 6.75 4.503 4.673 8.101h-.007z', + }, + gemini: { + viewBox: '0 0 192 192', + // Official Google Gemini sparkle logo from gemini.google.com + path: 'M164.93 86.68c-13.56-5.84-25.42-13.84-35.6-24.01-10.17-10.17-18.18-22.04-24.01-35.6-2.23-5.19-4.04-10.54-5.42-16.02C99.45 9.26 97.85 8 96 8s-3.45 1.26-3.9 3.05c-1.38 5.48-3.18 10.81-5.42 16.02-5.84 13.56-13.84 25.43-24.01 35.6-10.17 10.16-22.04 18.17-35.6 24.01-5.19 2.23-10.54 4.04-16.02 5.42C9.26 92.55 8 94.15 8 96s1.26 3.45 3.05 3.9c5.48 1.38 10.81 3.18 16.02 5.42 13.56 5.84 25.42 13.84 35.6 24.01 10.17 10.17 18.18 22.04 24.01 35.6 2.24 5.2 4.04 10.54 5.42 16.02A4.03 4.03 0 0 0 96 184c1.85 0 3.45-1.26 3.9-3.05 1.38-5.48 3.18-10.81 5.42-16.02 5.84-13.56 13.84-25.42 24.01-35.6 10.17-10.17 22.04-18.18 35.6-24.01 5.2-2.24 10.54-4.04 16.02-5.42A4.03 4.03 0 0 0 184 96c0-1.85-1.26-3.45-3.05-3.9-5.48-1.38-10.81-3.18-16.02-5.42', + }, + grok: { + viewBox: '0 0 512 509.641', + // Official Grok/xAI logo - stylized symbol from grok.com + path: 'M213.235 306.019l178.976-180.002v.169l51.695-51.763c-.924 1.32-1.86 2.605-2.785 3.89-39.281 54.164-58.46 80.649-43.07 146.922l-.09-.101c10.61 45.11-.744 95.137-37.398 131.836-46.216 46.306-120.167 56.611-181.063 14.928l42.462-19.675c38.863 15.278 81.392 8.57 111.947-22.03 30.566-30.6 37.432-75.159 22.065-112.252-2.92-7.025-11.67-8.795-17.792-4.263l-124.947 92.341zm-25.786 22.437l-.033.034L68.094 435.217c7.565-10.429 16.957-20.294 26.327-30.149 26.428-27.803 52.653-55.359 36.654-94.302-21.422-52.112-8.952-113.177 30.724-152.898 41.243-41.254 101.98-51.661 152.706-30.758 11.23 4.172 21.016 10.114 28.638 15.639l-42.359 19.584c-39.44-16.563-84.629-5.299-112.207 22.313-37.298 37.308-44.84 102.003-1.128 143.81z', + }, +}; + +export interface ProviderIconProps extends Omit, 'viewBox'> { + provider: ProviderIconKey; + title?: string; +} + +export function ProviderIcon({ provider, title, className, ...props }: ProviderIconProps) { + const definition = PROVIDER_ICON_DEFINITIONS[provider]; + const { + role, + 'aria-label': ariaLabel, + 'aria-labelledby': ariaLabelledby, + 'aria-hidden': ariaHidden, + ...rest + } = props; + const hasAccessibleLabel = Boolean(title || ariaLabel || ariaLabelledby); + + return ( + + {title && {title}} + + + ); +} + +export function AnthropicIcon(props: Omit) { + return ; +} + +export function OpenAIIcon(props: Omit) { + return ; +} + +export function CursorIcon(props: Omit) { + return ; +} + +export function GeminiIcon(props: Omit) { + return ; +} + +export function GrokIcon(props: Omit) { + return ; +} + +export const PROVIDER_ICON_COMPONENTS: Record< + ModelProvider, + ComponentType<{ className?: string }> +> = { + claude: AnthropicIcon, + cursor: CursorIcon, // Default for Cursor provider (will be overridden by getProviderIconForModel) + codex: OpenAIIcon, +}; + +/** + * Get the underlying model icon based on the model string + * For Cursor models, detects whether it's Claude, GPT, Gemini, Grok, or Cursor-specific + */ +function getUnderlyingModelIcon(model?: AgentModel | string): ProviderIconKey { + if (!model) return 'anthropic'; + + const modelStr = typeof model === 'string' ? model.toLowerCase() : model; + + // Check for Cursor-specific models with underlying providers + if (modelStr.includes('sonnet') || modelStr.includes('opus') || modelStr.includes('claude')) { + return 'anthropic'; + } + if (modelStr.includes('gpt-') || modelStr.includes('codex')) { + return 'openai'; + } + if (modelStr.includes('gemini')) { + return 'gemini'; + } + if (modelStr.includes('grok')) { + return 'grok'; + } + if (modelStr.includes('cursor') || modelStr === 'auto' || modelStr === 'composer-1') { + return 'cursor'; + } + + // Default based on provider + const provider = getProviderFromModel(model); + if (provider === 'codex') return 'openai'; + if (provider === 'cursor') return 'cursor'; + return 'anthropic'; +} + +export function getProviderIconForModel( + model?: AgentModel | string +): ComponentType<{ className?: string }> { + const iconKey = getUnderlyingModelIcon(model); + + const iconMap: Record> = { + anthropic: AnthropicIcon, + openai: OpenAIIcon, + cursor: CursorIcon, + gemini: GeminiIcon, + grok: GrokIcon, + }; + + return iconMap[iconKey] || AnthropicIcon; +} diff --git a/apps/ui/src/components/views/board-view/shared/model-constants.ts b/apps/ui/src/components/views/board-view/shared/model-constants.ts index e287a61c..40aba2b7 100644 --- a/apps/ui/src/components/views/board-view/shared/model-constants.ts +++ b/apps/ui/src/components/views/board-view/shared/model-constants.ts @@ -1,6 +1,6 @@ -import type { ModelAlias, ThinkingLevel } from '@/store/app-store'; -import type { ModelProvider } from '@automaker/types'; -import { CURSOR_MODEL_MAP } from '@automaker/types'; +import type { ModelAlias } from '@/store/app-store'; +import type { ModelProvider, ThinkingLevel, ReasoningEffort } from '@automaker/types'; +import { CURSOR_MODEL_MAP, CODEX_MODEL_MAP } from '@automaker/types'; import { Brain, Zap, Scale, Cpu, Rocket, Sparkles } from 'lucide-react'; export type ModelOption = { @@ -51,9 +51,64 @@ export const CURSOR_MODELS: ModelOption[] = Object.entries(CURSOR_MODEL_MAP).map ); /** - * All available models (Claude + Cursor) + * Codex/OpenAI models + * Official models from https://developers.openai.com/codex/models/ */ -export const ALL_MODELS: ModelOption[] = [...CLAUDE_MODELS, ...CURSOR_MODELS]; +export const CODEX_MODELS: ModelOption[] = [ + { + id: CODEX_MODEL_MAP.gpt52Codex, + label: 'GPT-5.2-Codex', + description: 'Most advanced agentic coding model (default for ChatGPT users).', + badge: 'Premium', + provider: 'codex', + hasThinking: true, + }, + { + id: CODEX_MODEL_MAP.gpt5Codex, + label: 'GPT-5-Codex', + description: 'Purpose-built for Codex CLI (default for CLI users).', + badge: 'Balanced', + provider: 'codex', + hasThinking: true, + }, + { + id: CODEX_MODEL_MAP.gpt5CodexMini, + label: 'GPT-5-Codex-Mini', + description: 'Faster workflows for code Q&A and editing.', + badge: 'Speed', + provider: 'codex', + hasThinking: false, + }, + { + id: CODEX_MODEL_MAP.codex1, + label: 'Codex-1', + description: 'o3-based model optimized for software engineering.', + badge: 'Premium', + provider: 'codex', + hasThinking: true, + }, + { + id: CODEX_MODEL_MAP.codexMiniLatest, + label: 'Codex-Mini-Latest', + description: 'o4-mini-based model for faster workflows.', + badge: 'Balanced', + provider: 'codex', + hasThinking: false, + }, + { + id: CODEX_MODEL_MAP.gpt5, + label: 'GPT-5', + description: 'GPT-5 base flagship model.', + badge: 'Balanced', + provider: 'codex', + hasThinking: true, + }, +]; + +/** + * All available models (Claude + Cursor + Codex) + */ +export const ALL_MODELS: ModelOption[] = [...CLAUDE_MODELS, ...CURSOR_MODELS, ...CODEX_MODELS]; export const THINKING_LEVELS: ThinkingLevel[] = ['none', 'low', 'medium', 'high', 'ultrathink']; @@ -65,6 +120,28 @@ export const THINKING_LEVEL_LABELS: Record = { ultrathink: 'Ultra', }; +/** + * Reasoning effort levels for Codex/OpenAI models + * All models support reasoning effort levels + */ +export const REASONING_EFFORT_LEVELS: ReasoningEffort[] = [ + 'none', + 'minimal', + 'low', + 'medium', + 'high', + 'xhigh', +]; + +export const REASONING_EFFORT_LABELS: Record = { + none: 'None', + minimal: 'Min', + low: 'Low', + medium: 'Med', + high: 'High', + xhigh: 'XHigh', +}; + // Profile icon mapping export const PROFILE_ICONS: Record> = { Brain, diff --git a/apps/ui/src/components/views/board-view/shared/model-selector.tsx b/apps/ui/src/components/views/board-view/shared/model-selector.tsx index 64cd8f35..fae4bf51 100644 --- a/apps/ui/src/components/views/board-view/shared/model-selector.tsx +++ b/apps/ui/src/components/views/board-view/shared/model-selector.tsx @@ -1,13 +1,14 @@ import { Label } from '@/components/ui/label'; import { Badge } from '@/components/ui/badge'; -import { Brain, Bot, Terminal, AlertTriangle } from 'lucide-react'; +import { Brain, AlertTriangle } from 'lucide-react'; +import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon'; import { cn } from '@/lib/utils'; import type { ModelAlias } from '@/store/app-store'; import { useAppStore } from '@/store/app-store'; import { useSetupStore } from '@/store/setup-store'; import { getModelProvider, PROVIDER_PREFIXES, stripProviderPrefix } from '@automaker/types'; import type { ModelProvider } from '@automaker/types'; -import { CLAUDE_MODELS, CURSOR_MODELS, ModelOption } from './model-constants'; +import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, ModelOption } from './model-constants'; interface ModelSelectorProps { selectedModel: string; // Can be ModelAlias or "cursor-{id}" @@ -21,13 +22,16 @@ export function ModelSelector({ testIdPrefix = 'model-select', }: ModelSelectorProps) { const { enabledCursorModels, cursorDefaultModel } = useAppStore(); - const { cursorCliStatus } = useSetupStore(); + const { cursorCliStatus, codexCliStatus } = useSetupStore(); const selectedProvider = getModelProvider(selectedModel); // Check if Cursor CLI is available const isCursorAvailable = cursorCliStatus?.installed && cursorCliStatus?.auth?.authenticated; + // Check if Codex CLI is available + const isCodexAvailable = codexCliStatus?.installed && codexCliStatus?.auth?.authenticated; + // Filter Cursor models based on enabled models from global settings const filteredCursorModels = CURSOR_MODELS.filter((model) => { // Extract the cursor model ID from the prefixed ID (e.g., "cursor-auto" -> "auto") @@ -39,6 +43,9 @@ export function ModelSelector({ if (provider === 'cursor' && selectedProvider !== 'cursor') { // Switch to Cursor's default model (from global settings) onModelSelect(`${PROVIDER_PREFIXES.cursor}${cursorDefaultModel}`); + } else if (provider === 'codex' && selectedProvider !== 'codex') { + // Switch to Codex's default model (gpt-5.2) + onModelSelect('gpt-5.2'); } else if (provider === 'claude' && selectedProvider !== 'claude') { // Switch to Claude's default model onModelSelect('sonnet'); @@ -62,7 +69,7 @@ export function ModelSelector({ )} data-testid={`${testIdPrefix}-provider-claude`} > - + Claude + @@ -136,7 +157,7 @@ export function ModelSelector({
@@ -188,6 +209,67 @@ export function ModelSelector({
)} + + {/* Codex Models */} + {selectedProvider === 'codex' && ( +
+ {/* Warning when Codex CLI is not available */} + {!isCodexAvailable && ( +
+ +
+ Codex CLI is not installed or authenticated. Configure it in Settings → AI + Providers. +
+
+ )} + +
+ + + CLI + +
+
+ {CODEX_MODELS.map((option) => { + const isSelected = selectedModel === option.id; + return ( + + ); + })} +
+
+ )} ); } diff --git a/apps/ui/src/components/views/profiles-view/components/profile-form.tsx b/apps/ui/src/components/views/profiles-view/components/profile-form.tsx index 9b306c1f..c42881df 100644 --- a/apps/ui/src/components/views/profiles-view/components/profile-form.tsx +++ b/apps/ui/src/components/views/profiles-view/components/profile-form.tsx @@ -7,7 +7,8 @@ import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; import { cn, modelSupportsThinking } from '@/lib/utils'; import { DialogFooter } from '@/components/ui/dialog'; -import { Brain, Bot, Terminal } from 'lucide-react'; +import { Brain } from 'lucide-react'; +import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon'; import { toast } from 'sonner'; import type { AIProfile, @@ -15,8 +16,9 @@ import type { ThinkingLevel, ModelProvider, CursorModelId, + CodexModelId, } from '@automaker/types'; -import { CURSOR_MODEL_MAP, cursorModelHasThinking } from '@automaker/types'; +import { CURSOR_MODEL_MAP, cursorModelHasThinking, CODEX_MODEL_MAP } from '@automaker/types'; import { useAppStore } from '@/store/app-store'; import { CLAUDE_MODELS, THINKING_LEVELS, ICON_OPTIONS } from '../constants'; @@ -46,6 +48,8 @@ export function ProfileForm({ thinkingLevel: profile.thinkingLevel || ('none' as ThinkingLevel), // Cursor-specific cursorModel: profile.cursorModel || ('auto' as CursorModelId), + // Codex-specific + codexModel: profile.codexModel || ('gpt-5.2' as CodexModelId), icon: profile.icon || 'Brain', }); @@ -59,6 +63,7 @@ export function ProfileForm({ model: provider === 'claude' ? 'sonnet' : formData.model, thinkingLevel: provider === 'claude' ? 'none' : formData.thinkingLevel, cursorModel: provider === 'cursor' ? 'auto' : formData.cursorModel, + codexModel: provider === 'codex' ? 'gpt-5.2' : formData.codexModel, }); }; @@ -76,6 +81,13 @@ export function ProfileForm({ }); }; + const handleCodexModelChange = (codexModel: CodexModelId) => { + setFormData({ + ...formData, + codexModel, + }); + }; + const handleSubmit = () => { if (!formData.name.trim()) { toast.error('Please enter a profile name'); @@ -95,6 +107,11 @@ export function ProfileForm({ ...baseProfile, cursorModel: formData.cursorModel, }); + } else if (formData.provider === 'codex') { + onSave({ + ...baseProfile, + codexModel: formData.codexModel, + }); } else { onSave({ ...baseProfile, @@ -158,34 +175,48 @@ export function ProfileForm({ {/* Provider Selection */}
-
+
+
@@ -222,7 +253,7 @@ export function ProfileForm({ {formData.provider === 'cursor' && (
@@ -283,6 +314,77 @@ export function ProfileForm({
)} + {/* Codex Model Selection */} + {formData.provider === 'codex' && ( +
+ +
+ {Object.entries(CODEX_MODEL_MAP).map(([key, modelId]) => { + const modelConfig = { + gpt52Codex: { label: 'GPT-5.2-Codex', badge: 'Premium', hasReasoning: true }, + gpt52: { label: 'GPT-5.2', badge: 'Premium', hasReasoning: true }, + gpt51CodexMax: { + label: 'GPT-5.1-Codex-Max', + badge: 'Premium', + hasReasoning: true, + }, + gpt51Codex: { label: 'GPT-5.1-Codex', badge: 'Balanced' }, + gpt51CodexMini: { label: 'GPT-5.1-Codex-Mini', badge: 'Speed' }, + gpt51: { label: 'GPT-5.1', badge: 'Standard' }, + o3Mini: { label: 'o3-mini', badge: 'Reasoning', hasReasoning: true }, + o4Mini: { label: 'o4-mini', badge: 'Reasoning', hasReasoning: true }, + }[key as keyof typeof CODEX_MODEL_MAP] || { label: modelId, badge: 'Standard' }; + + return ( + + ); + })} +
+
+ )} + {/* Claude Thinking Level */} {formData.provider === 'claude' && supportsThinking && (
diff --git a/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx b/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx index c808c37a..a777157e 100644 --- a/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx +++ b/apps/ui/src/components/views/settings-view/cli-status/claude-cli-status.tsx @@ -1,8 +1,9 @@ import { Button } from '@/components/ui/button'; -import { Terminal, CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react'; +import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; import type { CliStatus } from '../shared/types'; import type { ClaudeAuthStatus } from '@/store/setup-store'; +import { AnthropicIcon } from '@/components/ui/provider-icon'; interface CliStatusProps { status: CliStatus | null; @@ -95,7 +96,7 @@ export function ClaudeCliStatus({ status, authStatus, isChecking, onRefresh }: C
- +

Claude Code CLI diff --git a/apps/ui/src/components/views/settings-view/cli-status/cli-status-card.tsx b/apps/ui/src/components/views/settings-view/cli-status/cli-status-card.tsx new file mode 100644 index 00000000..dd194c1f --- /dev/null +++ b/apps/ui/src/components/views/settings-view/cli-status/cli-status-card.tsx @@ -0,0 +1,151 @@ +import { Button } from '@/components/ui/button'; +import { CheckCircle2, AlertCircle, RefreshCw } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import type { CliStatus } from '../shared/types'; + +interface CliStatusCardProps { + title: string; + description: string; + status: CliStatus | null; + isChecking: boolean; + onRefresh: () => void; + refreshTestId: string; + icon: React.ComponentType<{ className?: string }>; + fallbackRecommendation: string; +} + +export function CliStatusCard({ + title, + description, + status, + isChecking, + onRefresh, + refreshTestId, + icon: Icon, + fallbackRecommendation, +}: CliStatusCardProps) { + if (!status) return null; + + return ( +
+
+
+
+
+ +
+

{title}

+
+ +
+

{description}

+
+
+ {status.success && status.status === 'installed' ? ( +
+
+
+ +
+
+

{title} Installed

+
+ {status.method && ( +

+ Method: {status.method} +

+ )} + {status.version && ( +

+ Version: {status.version} +

+ )} + {status.path && ( +

+ Path: {status.path} +

+ )} +
+
+
+ {status.recommendation && ( +

{status.recommendation}

+ )} +
+ ) : ( +
+
+
+ +
+
+

{title} Not Detected

+

+ {status.recommendation || fallbackRecommendation} +

+
+
+ {status.installCommands && ( +
+

Installation Commands:

+
+ {status.installCommands.npm && ( +
+

+ npm +

+ + {status.installCommands.npm} + +
+ )} + {status.installCommands.macos && ( +
+

+ macOS/Linux +

+ + {status.installCommands.macos} + +
+ )} + {status.installCommands.windows && ( +
+

+ Windows (PowerShell) +

+ + {status.installCommands.windows} + +
+ )} +
+
+ )} +
+ )} +
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/cli-status/codex-cli-status.tsx b/apps/ui/src/components/views/settings-view/cli-status/codex-cli-status.tsx new file mode 100644 index 00000000..3e267a72 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/cli-status/codex-cli-status.tsx @@ -0,0 +1,24 @@ +import type { CliStatus } from '../shared/types'; +import { CliStatusCard } from './cli-status-card'; +import { OpenAIIcon } from '@/components/ui/provider-icon'; + +interface CliStatusProps { + status: CliStatus | null; + isChecking: boolean; + onRefresh: () => void; +} + +export function CodexCliStatus({ status, isChecking, onRefresh }: CliStatusProps) { + return ( + + ); +} diff --git a/apps/ui/src/components/views/settings-view/cli-status/cursor-cli-status.tsx b/apps/ui/src/components/views/settings-view/cli-status/cursor-cli-status.tsx index ebcec5ab..ddc7fd24 100644 --- a/apps/ui/src/components/views/settings-view/cli-status/cursor-cli-status.tsx +++ b/apps/ui/src/components/views/settings-view/cli-status/cursor-cli-status.tsx @@ -1,6 +1,7 @@ import { Button } from '@/components/ui/button'; -import { Terminal, CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react'; +import { CheckCircle2, AlertCircle, RefreshCw, XCircle } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { CursorIcon } from '@/components/ui/provider-icon'; interface CursorStatus { installed: boolean; @@ -215,7 +216,7 @@ export function CursorCliStatus({ status, isChecking, onRefresh }: CursorCliStat
- +

Cursor CLI

diff --git a/apps/ui/src/components/views/settings-view/codex/codex-settings.tsx b/apps/ui/src/components/views/settings-view/codex/codex-settings.tsx new file mode 100644 index 00000000..d603337c --- /dev/null +++ b/apps/ui/src/components/views/settings-view/codex/codex-settings.tsx @@ -0,0 +1,250 @@ +import { Label } from '@/components/ui/label'; +import { Checkbox } from '@/components/ui/checkbox'; +import { FileCode, ShieldCheck, Globe, ImageIcon } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import type { CodexApprovalPolicy, CodexSandboxMode } from '@automaker/types'; +import { OpenAIIcon } from '@/components/ui/provider-icon'; + +interface CodexSettingsProps { + autoLoadCodexAgents: boolean; + codexSandboxMode: CodexSandboxMode; + codexApprovalPolicy: CodexApprovalPolicy; + codexEnableWebSearch: boolean; + codexEnableImages: boolean; + onAutoLoadCodexAgentsChange: (enabled: boolean) => void; + onCodexSandboxModeChange: (mode: CodexSandboxMode) => void; + onCodexApprovalPolicyChange: (policy: CodexApprovalPolicy) => void; + onCodexEnableWebSearchChange: (enabled: boolean) => void; + onCodexEnableImagesChange: (enabled: boolean) => void; +} + +const CARD_TITLE = 'Codex CLI Settings'; +const CARD_SUBTITLE = 'Configure Codex instructions, capabilities, and execution safety defaults.'; +const AGENTS_TITLE = 'Auto-load AGENTS.md Instructions'; +const AGENTS_DESCRIPTION = 'Automatically inject project instructions from'; +const AGENTS_PATH = '.codex/AGENTS.md'; +const AGENTS_SUFFIX = 'on each Codex run.'; +const WEB_SEARCH_TITLE = 'Enable Web Search'; +const WEB_SEARCH_DESCRIPTION = + 'Allow Codex to search the web for current information using --search flag.'; +const IMAGES_TITLE = 'Enable Image Support'; +const IMAGES_DESCRIPTION = 'Allow Codex to process images attached to prompts using -i flag.'; +const SANDBOX_TITLE = 'Sandbox Policy'; +const APPROVAL_TITLE = 'Approval Policy'; +const SANDBOX_SELECT_LABEL = 'Select sandbox policy'; +const APPROVAL_SELECT_LABEL = 'Select approval policy'; + +const SANDBOX_OPTIONS: Array<{ + value: CodexSandboxMode; + label: string; + description: string; +}> = [ + { + value: 'read-only', + label: 'Read-only', + description: 'Only allow safe, non-mutating commands.', + }, + { + value: 'workspace-write', + label: 'Workspace write', + description: 'Allow file edits inside the project workspace.', + }, + { + value: 'danger-full-access', + label: 'Full access', + description: 'Allow unrestricted commands (use with care).', + }, +]; + +const APPROVAL_OPTIONS: Array<{ + value: CodexApprovalPolicy; + label: string; + description: string; +}> = [ + { + value: 'untrusted', + label: 'Untrusted', + description: 'Ask for approval for most commands.', + }, + { + value: 'on-failure', + label: 'On failure', + description: 'Ask only if a command fails in the sandbox.', + }, + { + value: 'on-request', + label: 'On request', + description: 'Let the agent decide when to ask.', + }, + { + value: 'never', + label: 'Never', + description: 'Never ask for approval (least restrictive).', + }, +]; + +export function CodexSettings({ + autoLoadCodexAgents, + codexSandboxMode, + codexApprovalPolicy, + codexEnableWebSearch, + codexEnableImages, + onAutoLoadCodexAgentsChange, + onCodexSandboxModeChange, + onCodexApprovalPolicyChange, + onCodexEnableWebSearchChange, + onCodexEnableImagesChange, +}: CodexSettingsProps) { + const sandboxOption = SANDBOX_OPTIONS.find((option) => option.value === codexSandboxMode); + const approvalOption = APPROVAL_OPTIONS.find((option) => option.value === codexApprovalPolicy); + + return ( +
+
+
+
+ +
+

{CARD_TITLE}

+
+

{CARD_SUBTITLE}

+
+
+
+ onAutoLoadCodexAgentsChange(checked === true)} + className="mt-1" + data-testid="auto-load-codex-agents-checkbox" + /> +
+ +

+ {AGENTS_DESCRIPTION}{' '} + {AGENTS_PATH}{' '} + {AGENTS_SUFFIX} +

+
+
+ +
+ onCodexEnableWebSearchChange(checked === true)} + className="mt-1" + data-testid="codex-enable-web-search-checkbox" + /> +
+ +

+ {WEB_SEARCH_DESCRIPTION} +

+
+
+ +
+ onCodexEnableImagesChange(checked === true)} + className="mt-1" + data-testid="codex-enable-images-checkbox" + /> +
+ +

{IMAGES_DESCRIPTION}

+
+
+ +
+
+ +
+
+
+
+ +

+ {sandboxOption?.description} +

+
+ +
+ +
+
+ +

+ {approvalOption?.description} +

+
+ +
+
+
+
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx b/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx new file mode 100644 index 00000000..6e336e4b --- /dev/null +++ b/apps/ui/src/components/views/settings-view/codex/codex-usage-section.tsx @@ -0,0 +1,237 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { RefreshCw, AlertCircle } from 'lucide-react'; +import { OpenAIIcon } from '@/components/ui/provider-icon'; +import { cn } from '@/lib/utils'; +import { getElectronAPI } from '@/lib/electron'; +import { + formatCodexCredits, + formatCodexPlanType, + formatCodexResetTime, + getCodexWindowLabel, +} from '@/lib/codex-usage-format'; +import { useSetupStore } from '@/store/setup-store'; +import { useAppStore, type CodexRateLimitWindow } from '@/store/app-store'; + +const ERROR_NO_API = 'Codex usage API not available'; +const CODEX_USAGE_TITLE = 'Codex Usage'; +const CODEX_USAGE_SUBTITLE = 'Shows usage limits reported by the Codex CLI.'; +const CODEX_AUTH_WARNING = 'Authenticate Codex CLI to view usage limits.'; +const CODEX_LOGIN_COMMAND = 'codex login'; +const CODEX_NO_USAGE_MESSAGE = + 'Usage limits are not available yet. Try refreshing if this persists.'; +const UPDATED_LABEL = 'Updated'; +const CODEX_FETCH_ERROR = 'Failed to fetch usage'; +const CODEX_REFRESH_LABEL = 'Refresh Codex usage'; +const PLAN_LABEL = 'Plan'; +const CREDITS_LABEL = 'Credits'; +const WARNING_THRESHOLD = 75; +const CAUTION_THRESHOLD = 50; +const MAX_PERCENTAGE = 100; +const REFRESH_INTERVAL_MS = 60_000; +const STALE_THRESHOLD_MS = 2 * 60_000; +const USAGE_COLOR_CRITICAL = 'bg-red-500'; +const USAGE_COLOR_WARNING = 'bg-amber-500'; +const USAGE_COLOR_OK = 'bg-emerald-500'; + +const isRateLimitWindow = ( + limitWindow: CodexRateLimitWindow | null +): limitWindow is CodexRateLimitWindow => Boolean(limitWindow); + +export function CodexUsageSection() { + const codexAuthStatus = useSetupStore((state) => state.codexAuthStatus); + const { codexUsage, codexUsageLastUpdated, setCodexUsage } = useAppStore(); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const canFetchUsage = !!codexAuthStatus?.authenticated; + const rateLimits = codexUsage?.rateLimits ?? null; + const primary = rateLimits?.primary ?? null; + const secondary = rateLimits?.secondary ?? null; + const credits = rateLimits?.credits ?? null; + const planType = rateLimits?.planType ?? null; + const rateLimitWindows = [primary, secondary].filter(isRateLimitWindow); + const hasMetrics = rateLimitWindows.length > 0; + const lastUpdatedLabel = codexUsage?.lastUpdated + ? new Date(codexUsage.lastUpdated).toLocaleString() + : null; + const showAuthWarning = !canFetchUsage && !codexUsage && !isLoading; + const isStale = !codexUsageLastUpdated || Date.now() - codexUsageLastUpdated > STALE_THRESHOLD_MS; + + const fetchUsage = useCallback(async () => { + setIsLoading(true); + setError(null); + try { + const api = getElectronAPI(); + if (!api.codex) { + setError(ERROR_NO_API); + return; + } + const result = await api.codex.getUsage(); + if ('error' in result) { + setError(result.message || result.error); + return; + } + setCodexUsage(result); + } catch (fetchError) { + const message = fetchError instanceof Error ? fetchError.message : CODEX_FETCH_ERROR; + setError(message); + } finally { + setIsLoading(false); + } + }, [setCodexUsage]); + + useEffect(() => { + if (canFetchUsage && isStale) { + void fetchUsage(); + } + }, [fetchUsage, canFetchUsage, isStale]); + + useEffect(() => { + if (!canFetchUsage) return undefined; + + const intervalId = setInterval(() => { + void fetchUsage(); + }, REFRESH_INTERVAL_MS); + + return () => clearInterval(intervalId); + }, [fetchUsage, canFetchUsage]); + + const getUsageColor = (percentage: number) => { + if (percentage >= WARNING_THRESHOLD) { + return USAGE_COLOR_CRITICAL; + } + if (percentage >= CAUTION_THRESHOLD) { + return USAGE_COLOR_WARNING; + } + return USAGE_COLOR_OK; + }; + + const RateLimitCard = ({ + title, + subtitle, + window: limitWindow, + }: { + title: string; + subtitle: string; + window: CodexRateLimitWindow; + }) => { + const safePercentage = Math.min(Math.max(limitWindow.usedPercent, 0), MAX_PERCENTAGE); + const resetLabel = formatCodexResetTime(limitWindow.resetsAt); + + return ( +
+
+
+

{title}

+

{subtitle}

+
+ + {Math.round(safePercentage)}% + +
+
+
+
+ {resetLabel &&

{resetLabel}

} +
+ ); + }; + + return ( +
+
+
+
+ +
+

+ {CODEX_USAGE_TITLE} +

+ +
+

{CODEX_USAGE_SUBTITLE}

+
+
+ {showAuthWarning && ( +
+ +
+ {CODEX_AUTH_WARNING} Run {CODEX_LOGIN_COMMAND}. +
+
+ )} + {error && ( +
+ +
{error}
+
+ )} + {hasMetrics && ( +
+ {rateLimitWindows.map((limitWindow, index) => { + const { title, subtitle } = getCodexWindowLabel(limitWindow.windowDurationMins); + return ( + + ); + })} +
+ )} + {(planType || credits) && ( +
+ {planType && ( +
+ {PLAN_LABEL}:{' '} + {formatCodexPlanType(planType)} +
+ )} + {credits && ( +
+ {CREDITS_LABEL}:{' '} + {formatCodexCredits(credits)} +
+ )} +
+ )} + {!hasMetrics && !error && canFetchUsage && !isLoading && ( +
+ {CODEX_NO_USAGE_MESSAGE} +
+ )} + {lastUpdatedLabel && ( +
+ {UPDATED_LABEL} {lastUpdatedLabel} +
+ )} +
+
+ ); +} diff --git a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx index 8294c9fb..323fe258 100644 --- a/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx +++ b/apps/ui/src/components/views/settings-view/model-defaults/phase-model-selector.tsx @@ -19,10 +19,12 @@ import { import { CLAUDE_MODELS, CURSOR_MODELS, + CODEX_MODELS, THINKING_LEVELS, THINKING_LEVEL_LABELS, } from '@/components/views/board-view/shared/model-constants'; -import { Check, ChevronsUpDown, Star, Brain, Sparkles, ChevronRight } from 'lucide-react'; +import { Check, ChevronsUpDown, Star, ChevronRight } from 'lucide-react'; +import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon'; import { Button } from '@/components/ui/button'; import { Command, @@ -140,14 +142,14 @@ export function PhaseModelSelector({ return { ...claudeModel, label: `${claudeModel.label}${thinkingLabel}`, - icon: Brain, + icon: AnthropicIcon, }; } const cursorModel = availableCursorModels.find( (m) => stripProviderPrefix(m.id) === selectedModel ); - if (cursorModel) return { ...cursorModel, icon: Sparkles }; + if (cursorModel) return { ...cursorModel, icon: CursorIcon }; // Check if selectedModel is part of a grouped model const group = getModelGroup(selectedModel as CursorModelId); @@ -158,10 +160,14 @@ export function PhaseModelSelector({ label: `${group.label} (${variant?.label || 'Unknown'})`, description: group.description, provider: 'cursor' as const, - icon: Sparkles, + icon: CursorIcon, }; } + // Check Codex models + const codexModel = CODEX_MODELS.find((m) => m.id === selectedModel); + if (codexModel) return { ...codexModel, icon: OpenAIIcon }; + return null; }, [selectedModel, selectedThinkingLevel, availableCursorModels]); @@ -199,10 +205,11 @@ export function PhaseModelSelector({ }, [availableCursorModels, enabledCursorModels]); // Group models - const { favorites, claude, cursor } = React.useMemo(() => { + const { favorites, claude, cursor, codex } = React.useMemo(() => { const favs: typeof CLAUDE_MODELS = []; const cModels: typeof CLAUDE_MODELS = []; const curModels: typeof CURSOR_MODELS = []; + const codModels: typeof CODEX_MODELS = []; // Process Claude Models CLAUDE_MODELS.forEach((model) => { @@ -222,9 +229,71 @@ export function PhaseModelSelector({ } }); - return { favorites: favs, claude: cModels, cursor: curModels }; + // Process Codex Models + CODEX_MODELS.forEach((model) => { + if (favoriteModels.includes(model.id)) { + favs.push(model); + } else { + codModels.push(model); + } + }); + + return { favorites: favs, claude: cModels, cursor: curModels, codex: codModels }; }, [favoriteModels, availableCursorModels]); + // Render Codex model item (no thinking level needed) + const renderCodexModelItem = (model: (typeof CODEX_MODELS)[0]) => { + const isSelected = selectedModel === model.id; + const isFavorite = favoriteModels.includes(model.id); + + return ( + { + onChange({ model: model.id }); + setOpen(false); + }} + className="group flex items-center justify-between py-2" + > +
+ +
+ + {model.label} + + {model.description} +
+
+ +
+ + {isSelected && } +
+
+ ); + }; + // Render Cursor model item (no thinking level needed) const renderCursorModelItem = (model: (typeof CURSOR_MODELS)[0]) => { const modelValue = stripProviderPrefix(model.id); @@ -242,7 +311,7 @@ export function PhaseModelSelector({ className="group flex items-center justify-between py-2" >
-
-
- renderCursorModelItem(model))} )} + + {codex.length > 0 && ( + + {codex.map((model) => renderCodexModelItem(model))} + + )} 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 new file mode 100644 index 00000000..4b5f2e36 --- /dev/null +++ b/apps/ui/src/components/views/settings-view/providers/codex-settings-tab.tsx @@ -0,0 +1,92 @@ +import { useState, useCallback } 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 { getElectronAPI } from '@/lib/electron'; +import { createLogger } from '@automaker/utils/logger'; + +const logger = createLogger('CodexSettings'); + +export function CodexSettingsTab() { + const { + codexAutoLoadAgents, + setCodexAutoLoadAgents, + codexSandboxMode, + setCodexSandboxMode, + codexApprovalPolicy, + setCodexApprovalPolicy, + } = useAppStore(); + const { codexAuthStatus, codexCliStatus, setCodexCliStatus, setCodexAuthStatus } = + useSetupStore(); + + const [isCheckingCodexCli, setIsCheckingCodexCli] = useState(false); + + const handleRefreshCodexCli = useCallback(async () => { + setIsCheckingCodexCli(true); + try { + const api = getElectronAPI(); + if (api?.setup?.getCodexStatus) { + const result = await api.setup.getCodexStatus(); + if (result.success) { + setCodexCliStatus({ + installed: result.installed, + version: result.version, + path: result.path, + method: result.method, + }); + if (result.auth) { + setCodexAuthStatus({ + authenticated: result.auth.authenticated, + method: result.auth.method, + hasAuthFile: result.auth.hasAuthFile, + hasOAuthToken: result.auth.hasOAuthToken, + hasApiKey: result.auth.hasApiKey, + }); + } + } + } + } catch (error) { + logger.error('Failed to refresh Codex CLI status:', error); + } finally { + setIsCheckingCodexCli(false); + } + }, [setCodexCliStatus, setCodexAuthStatus]); + + // Show usage tracking when CLI is authenticated + const showUsageTracking = codexAuthStatus?.authenticated ?? false; + + return ( +
+ {/* Usage Info */} +
+ +
+ OpenAI via Codex CLI +

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

+
+
+ + + + {showUsageTracking && } +
+ ); +} + +export default CodexSettingsTab; diff --git a/apps/ui/src/components/views/settings-view/providers/index.ts b/apps/ui/src/components/views/settings-view/providers/index.ts index c9284867..6711dedd 100644 --- a/apps/ui/src/components/views/settings-view/providers/index.ts +++ b/apps/ui/src/components/views/settings-view/providers/index.ts @@ -1,3 +1,4 @@ export { ProviderTabs } from './provider-tabs'; export { ClaudeSettingsTab } from './claude-settings-tab'; export { CursorSettingsTab } from './cursor-settings-tab'; +export { CodexSettingsTab } from './codex-settings-tab'; diff --git a/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx b/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx index dc97cf2f..56305aad 100644 --- a/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx +++ b/apps/ui/src/components/views/settings-view/providers/provider-tabs.tsx @@ -1,25 +1,30 @@ import React from 'react'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Bot, Terminal } from 'lucide-react'; +import { AnthropicIcon, CursorIcon, OpenAIIcon } from '@/components/ui/provider-icon'; import { CursorSettingsTab } from './cursor-settings-tab'; import { ClaudeSettingsTab } from './claude-settings-tab'; +import { CodexSettingsTab } from './codex-settings-tab'; interface ProviderTabsProps { - defaultTab?: 'claude' | 'cursor'; + defaultTab?: 'claude' | 'cursor' | 'codex'; } export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) { return ( - + - + Claude - + Cursor + + + Codex + @@ -29,6 +34,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) { + + + + ); } diff --git a/apps/ui/src/components/views/setup-view.tsx b/apps/ui/src/components/views/setup-view.tsx index 6a109213..a15944b2 100644 --- a/apps/ui/src/components/views/setup-view.tsx +++ b/apps/ui/src/components/views/setup-view.tsx @@ -7,6 +7,7 @@ import { CompleteStep, ClaudeSetupStep, CursorSetupStep, + CodexSetupStep, GitHubSetupStep, } from './setup-view/steps'; import { useNavigate } from '@tanstack/react-router'; @@ -18,13 +19,14 @@ export function SetupView() { const { currentStep, setCurrentStep, completeSetup, setSkipClaudeSetup } = useSetupStore(); const navigate = useNavigate(); - const steps = ['welcome', 'theme', 'claude', 'cursor', 'github', 'complete'] as const; + const steps = ['welcome', 'theme', 'claude', 'cursor', 'codex', 'github', 'complete'] as const; type StepName = (typeof steps)[number]; const getStepName = (): StepName => { if (currentStep === 'claude_detect' || currentStep === 'claude_auth') return 'claude'; if (currentStep === 'welcome') return 'welcome'; if (currentStep === 'theme') return 'theme'; if (currentStep === 'cursor') return 'cursor'; + if (currentStep === 'codex') return 'codex'; if (currentStep === 'github') return 'github'; return 'complete'; }; @@ -46,6 +48,10 @@ export function SetupView() { setCurrentStep('cursor'); break; case 'cursor': + logger.debug('[Setup Flow] Moving to codex step'); + setCurrentStep('codex'); + break; + case 'codex': logger.debug('[Setup Flow] Moving to github step'); setCurrentStep('github'); break; @@ -68,9 +74,12 @@ export function SetupView() { case 'cursor': setCurrentStep('claude_detect'); break; - case 'github': + case 'codex': setCurrentStep('cursor'); break; + case 'github': + setCurrentStep('codex'); + break; } }; @@ -82,6 +91,11 @@ export function SetupView() { const handleSkipCursor = () => { logger.debug('[Setup Flow] Skipping Cursor setup'); + setCurrentStep('codex'); + }; + + const handleSkipCodex = () => { + logger.debug('[Setup Flow] Skipping Codex setup'); setCurrentStep('github'); }; @@ -139,6 +153,14 @@ export function SetupView() { /> )} + {currentStep === 'codex' && ( + handleNext('codex')} + onBack={() => handleBack('codex')} + onSkip={handleSkipCodex} + /> + )} + {currentStep === 'github' && ( handleNext('github')} diff --git a/apps/ui/src/components/views/setup-view/hooks/use-cli-status.ts b/apps/ui/src/components/views/setup-view/hooks/use-cli-status.ts index f543f34f..afae1645 100644 --- a/apps/ui/src/components/views/setup-view/hooks/use-cli-status.ts +++ b/apps/ui/src/components/views/setup-view/hooks/use-cli-status.ts @@ -2,13 +2,26 @@ import { useState, useCallback } from 'react'; import { createLogger } from '@automaker/utils/logger'; interface UseCliStatusOptions { - cliType: 'claude'; + cliType: 'claude' | 'codex'; statusApi: () => Promise; setCliStatus: (status: any) => void; setAuthStatus: (status: any) => void; } -// Create logger once outside the hook to prevent infinite re-renders +const VALID_AUTH_METHODS = { + claude: [ + 'oauth_token_env', + 'oauth_token', + 'api_key', + 'api_key_env', + 'credentials_file', + 'cli_authenticated', + 'none', + ], + codex: ['cli_authenticated', 'api_key', 'api_key_env', 'none'], +} as const; + +// Create logger outside of the hook to avoid re-creating it on every render const logger = createLogger('CliStatus'); export function useCliStatus({ @@ -38,29 +51,31 @@ export function useCliStatus({ if (result.auth) { // Validate method is one of the expected values, default to "none" - const validMethods = [ - 'oauth_token_env', - 'oauth_token', - 'api_key', - 'api_key_env', - 'credentials_file', - 'cli_authenticated', - 'none', - ] as const; + const validMethods = VALID_AUTH_METHODS[cliType] ?? ['none'] as const; type AuthMethod = (typeof validMethods)[number]; const method: AuthMethod = validMethods.includes(result.auth.method as AuthMethod) ? (result.auth.method as AuthMethod) : 'none'; - const authStatus = { - authenticated: result.auth.authenticated, - method, - hasCredentialsFile: false, - oauthTokenValid: result.auth.hasStoredOAuthToken || result.auth.hasEnvOAuthToken, - apiKeyValid: result.auth.hasStoredApiKey || result.auth.hasEnvApiKey, - hasEnvOAuthToken: result.auth.hasEnvOAuthToken, - hasEnvApiKey: result.auth.hasEnvApiKey, - }; - setAuthStatus(authStatus); + + if (cliType === 'claude') { + setAuthStatus({ + authenticated: result.auth.authenticated, + method, + hasCredentialsFile: false, + oauthTokenValid: result.auth.hasStoredOAuthToken || result.auth.hasEnvOAuthToken, + apiKeyValid: result.auth.hasStoredApiKey || result.auth.hasEnvApiKey, + hasEnvOAuthToken: result.auth.hasEnvOAuthToken, + hasEnvApiKey: result.auth.hasEnvApiKey, + }); + } else { + setAuthStatus({ + authenticated: result.auth.authenticated, + method, + hasAuthFile: result.auth.hasAuthFile ?? false, + hasApiKey: result.auth.hasApiKey ?? false, + hasEnvApiKey: result.auth.hasEnvApiKey ?? false, + }); + } } } } catch (error) { 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 new file mode 100644 index 00000000..d662b0dd --- /dev/null +++ b/apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx @@ -0,0 +1,809 @@ +import { useState, useEffect, useCallback } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion'; +import { useAppStore } from '@/store/app-store'; +import { getElectronAPI } from '@/lib/electron'; +import { + CheckCircle2, + Loader2, + Key, + ArrowRight, + ArrowLeft, + ExternalLink, + Copy, + RefreshCw, + Download, + Info, + ShieldCheck, + XCircle, + Trash2, +} from 'lucide-react'; +import { toast } from 'sonner'; +import { StatusBadge, TerminalOutput } from '../components'; +import { useCliStatus, useCliInstallation, useTokenSave } from '../hooks'; +import type { ApiKeys } from '@/store/app-store'; +import type { ModelProvider } from '@/store/app-store'; +import type { ProviderKey } from '@/config/api-providers'; +import type { + CliStatus, + InstallProgress, + ClaudeAuthStatus, + CodexAuthStatus, +} from '@/store/setup-store'; +import { PROVIDER_ICON_COMPONENTS } from '@/components/ui/provider-icon'; + +type VerificationStatus = 'idle' | 'verifying' | 'verified' | 'error'; + +type CliSetupAuthStatus = ClaudeAuthStatus | CodexAuthStatus; + +interface CliSetupConfig { + cliType: ModelProvider; + displayName: string; + cliLabel: string; + cliDescription: string; + apiKeyLabel: string; + apiKeyDescription: string; + apiKeyProvider: ProviderKey; + apiKeyPlaceholder: string; + apiKeyDocsUrl: string; + apiKeyDocsLabel: string; + installCommands: { + macos: string; + windows: string; + }; + cliLoginCommand: string; + testIds: { + installButton: string; + verifyCliButton: string; + verifyApiKeyButton: string; + apiKeyInput: string; + saveApiKeyButton: string; + deleteApiKeyButton: string; + nextButton: string; + }; + buildCliAuthStatus: (previous: CliSetupAuthStatus | null) => CliSetupAuthStatus; + buildApiKeyAuthStatus: (previous: CliSetupAuthStatus | null) => CliSetupAuthStatus; + buildClearedAuthStatus: (previous: CliSetupAuthStatus | null) => CliSetupAuthStatus; + statusApi: () => Promise; + installApi: () => Promise; + verifyAuthApi: (method: 'cli' | 'api_key') => Promise<{ + success: boolean; + authenticated: boolean; + error?: string; + }>; + apiKeyHelpText: string; +} + +interface CliSetupStateHandlers { + cliStatus: CliStatus | null; + authStatus: CliSetupAuthStatus | null; + setCliStatus: (status: CliStatus | null) => void; + setAuthStatus: (status: CliSetupAuthStatus | null) => void; + setInstallProgress: (progress: Partial) => void; + getStoreState: () => CliStatus | null; +} + +interface CliSetupStepProps { + config: CliSetupConfig; + state: CliSetupStateHandlers; + onNext: () => void; + onBack: () => void; + onSkip: () => void; +} + +export function CliSetupStep({ config, state, onNext, onBack, onSkip }: CliSetupStepProps) { + const { apiKeys, setApiKeys } = useAppStore(); + const { cliStatus, authStatus, setCliStatus, setAuthStatus, setInstallProgress, getStoreState } = + state; + + const [apiKey, setApiKey] = useState(''); + + const [cliVerificationStatus, setCliVerificationStatus] = useState('idle'); + const [cliVerificationError, setCliVerificationError] = useState(null); + + const [apiKeyVerificationStatus, setApiKeyVerificationStatus] = + useState('idle'); + const [apiKeyVerificationError, setApiKeyVerificationError] = useState(null); + + const [isDeletingApiKey, setIsDeletingApiKey] = useState(false); + + const statusApi = useCallback(() => config.statusApi(), [config]); + const installApi = useCallback(() => config.installApi(), [config]); + + const { isChecking, checkStatus } = useCliStatus({ + cliType: config.cliType, + statusApi, + setCliStatus, + setAuthStatus, + }); + + const onInstallSuccess = useCallback(() => { + checkStatus(); + }, [checkStatus]); + + const { isInstalling, installProgress, install } = useCliInstallation({ + cliType: config.cliType, + installApi, + onProgressEvent: getElectronAPI().setup?.onInstallProgress, + onSuccess: onInstallSuccess, + getStoreState, + }); + + const { isSaving: isSavingApiKey, saveToken: saveApiKeyToken } = useTokenSave({ + provider: config.apiKeyProvider, + onSuccess: () => { + setAuthStatus(config.buildApiKeyAuthStatus(authStatus)); + setApiKeys({ ...apiKeys, [config.apiKeyProvider]: apiKey }); + toast.success('API key saved successfully!'); + }, + }); + + const verifyCliAuth = useCallback(async () => { + setCliVerificationStatus('verifying'); + setCliVerificationError(null); + + try { + const result = await config.verifyAuthApi('cli'); + + const hasLimitOrBillingError = + result.error?.toLowerCase().includes('limit reached') || + result.error?.toLowerCase().includes('rate limit') || + result.error?.toLowerCase().includes('credit balance') || + result.error?.toLowerCase().includes('billing'); + + if (result.authenticated) { + // Auth succeeded - even if rate limited or billing issue + setCliVerificationStatus('verified'); + setAuthStatus(config.buildCliAuthStatus(authStatus)); + + if (hasLimitOrBillingError) { + // Show warning but keep auth verified + toast.warning(result.error || 'Rate limit or billing issue'); + } else { + toast.success(`${config.displayName} CLI authentication verified!`); + } + } else { + // Actual auth failure + setCliVerificationStatus('error'); + // Include detailed error if available + const errorDisplay = result.details + ? `${result.error}\n\nDetails: ${result.details}` + : result.error || 'Authentication failed'; + setCliVerificationError(errorDisplay); + setAuthStatus(config.buildClearedAuthStatus(authStatus)); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Verification failed'; + setCliVerificationStatus('error'); + setCliVerificationError(errorMessage); + } + }, [authStatus, config, setAuthStatus]); + + const verifyApiKeyAuth = useCallback(async () => { + setApiKeyVerificationStatus('verifying'); + setApiKeyVerificationError(null); + + try { + const result = await config.verifyAuthApi('api_key'); + + const hasLimitOrBillingError = + result.error?.toLowerCase().includes('limit reached') || + result.error?.toLowerCase().includes('rate limit') || + result.error?.toLowerCase().includes('credit balance') || + result.error?.toLowerCase().includes('billing'); + + if (result.authenticated) { + // Auth succeeded - even if rate limited or billing issue + setApiKeyVerificationStatus('verified'); + setAuthStatus(config.buildApiKeyAuthStatus(authStatus)); + + if (hasLimitOrBillingError) { + // Show warning but keep auth verified + toast.warning(result.error || 'Rate limit or billing issue'); + } else { + toast.success('API key authentication verified!'); + } + } else { + // Actual auth failure + setApiKeyVerificationStatus('error'); + // Include detailed error if available + const errorDisplay = result.details + ? `${result.error}\n\nDetails: ${result.details}` + : result.error || 'Authentication failed'; + setApiKeyVerificationError(errorDisplay); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Verification failed'; + setApiKeyVerificationStatus('error'); + setApiKeyVerificationError(errorMessage); + } + }, [authStatus, config, setAuthStatus]); + + const deleteApiKey = useCallback(async () => { + setIsDeletingApiKey(true); + try { + const api = getElectronAPI(); + if (!api.setup?.deleteApiKey) { + toast.error('Delete API not available'); + return; + } + + const result = await api.setup.deleteApiKey(config.apiKeyProvider); + if (result.success) { + setApiKey(''); + setApiKeys({ ...apiKeys, [config.apiKeyProvider]: '' }); + setApiKeyVerificationStatus('idle'); + setApiKeyVerificationError(null); + setAuthStatus(config.buildClearedAuthStatus(authStatus)); + toast.success('API key deleted successfully'); + } else { + toast.error(result.error || 'Failed to delete API key'); + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to delete API key'; + toast.error(errorMessage); + } finally { + setIsDeletingApiKey(false); + } + }, [apiKeys, authStatus, config, setApiKeys, setAuthStatus]); + + useEffect(() => { + setInstallProgress({ + isInstalling, + output: installProgress.output, + }); + }, [isInstalling, installProgress, setInstallProgress]); + + useEffect(() => { + checkStatus(); + }, [checkStatus]); + + const copyCommand = (command: string) => { + navigator.clipboard.writeText(command); + toast.success('Command copied to clipboard'); + }; + + const hasApiKey = + !!(apiKeys as ApiKeys)[config.apiKeyProvider] || + authStatus?.method === 'api_key' || + authStatus?.method === 'api_key_env'; + const isCliVerified = cliVerificationStatus === 'verified'; + const isApiKeyVerified = apiKeyVerificationStatus === 'verified'; + const isReady = isCliVerified || isApiKeyVerified; + const ProviderIcon = PROVIDER_ICON_COMPONENTS[config.cliType]; + + const getCliStatusBadge = () => { + if (cliVerificationStatus === 'verified') { + return ; + } + if (cliVerificationStatus === 'error') { + return ; + } + if (isChecking) { + return ; + } + if (cliStatus?.installed) { + return ; + } + return ; + }; + + const getApiKeyStatusBadge = () => { + if (apiKeyVerificationStatus === 'verified') { + return ; + } + if (apiKeyVerificationStatus === 'error') { + return ; + } + if (hasApiKey) { + return ; + } + return ; + }; + + return ( +
+
+
+ +
+

{config.displayName} Setup

+

Configure authentication for code generation

+
+ + + +
+ + + Authentication Methods + + +
+ Choose one of the following methods to authenticate: +
+ + + + +
+
+ +
+

{config.cliLabel}

+

{config.cliDescription}

+
+
+ {getCliStatusBadge()} +
+
+ + {!cliStatus?.installed && ( +
+
+ +

Install {config.cliLabel}

+
+ +
+ +
+ + {config.installCommands.macos} + + +
+
+ +
+ +
+ + {config.installCommands.windows} + + +
+
+ + {isInstalling && } + + +
+ )} + + {cliStatus?.installed && cliStatus?.version && ( +

Version: {cliStatus.version}

+ )} + + {cliVerificationStatus === 'verifying' && ( +
+ +
+

Verifying CLI authentication...

+

Running a test query

+
+
+ )} + + {cliVerificationStatus === 'verified' && ( +
+ +
+

CLI Authentication verified!

+

+ Your {config.displayName} CLI is working correctly. +

+
+
+ )} + + {cliVerificationStatus === 'error' && cliVerificationError && ( +
+ +
+

Verification failed

+ {(() => { + const parts = cliVerificationError.split('\n\nDetails: '); + const mainError = parts[0]; + const details = parts[1]; + const errorLower = cliVerificationError.toLowerCase(); + + // Check if this is actually a usage limit issue, not an auth problem + const isUsageLimitIssue = + errorLower.includes('usage limit') || + errorLower.includes('rate limit') || + errorLower.includes('limit reached') || + errorLower.includes('too many requests') || + errorLower.includes('credit balance') || + errorLower.includes('billing') || + errorLower.includes('insufficient credits') || + errorLower.includes('upgrade to pro'); + + // Categorize error and provide helpful suggestions + // IMPORTANT: Don't suggest re-authentication for usage limits! + const getHelpfulSuggestion = () => { + // Usage limit issue - NOT an authentication problem + if (isUsageLimitIssue) { + return { + title: 'Usage limit issue (not authentication)', + message: + 'Your login credentials are working fine. This is a rate limit or billing error.', + action: 'Wait a few minutes and try again, or check your billing', + }; + } + + // Token refresh failures + if ( + errorLower.includes('tokenrefresh') || + errorLower.includes('token refresh') + ) { + return { + title: 'Token refresh failed', + message: 'Your OAuth token needs to be refreshed.', + action: 'Re-authenticate', + command: config.cliLoginCommand, + }; + } + + // Connection/transport issues + if (errorLower.includes('transport channel closed')) { + return { + title: 'Connection issue', + message: + 'The connection to the authentication server was interrupted.', + action: 'Try again or re-authenticate', + command: config.cliLoginCommand, + }; + } + + // Invalid API key + if (errorLower.includes('invalid') && errorLower.includes('api key')) { + return { + title: 'Invalid API key', + message: 'Your API key is incorrect or has been revoked.', + action: 'Check your API key or get a new one', + }; + } + + // Expired token + if (errorLower.includes('expired')) { + return { + title: 'Token expired', + message: 'Your authentication token has expired.', + action: 'Re-authenticate', + command: config.cliLoginCommand, + }; + } + + // Authentication required + if (errorLower.includes('login') || errorLower.includes('authenticate')) { + return { + title: 'Authentication required', + message: 'You need to authenticate with your account.', + action: 'Run the login command', + command: config.cliLoginCommand, + }; + } + + return null; + }; + + const suggestion = getHelpfulSuggestion(); + + return ( + <> +

{mainError}

+ {details && ( +
+

+ Technical details: +

+
+                                  {details}
+                                
+
+ )} + {suggestion && ( +
+
+ + 💡 {suggestion.title} + +
+

+ {suggestion.message} +

+ {suggestion.command && ( + <> +

+ {suggestion.action}: +

+
+ + {suggestion.command} + + +
+ + )} + {!suggestion.command && ( +

+ → {suggestion.action} +

+ )} +
+ )} + + ); + })()} +
+
+ )} + + {cliVerificationStatus !== 'verified' && ( + + )} +
+
+ + + +
+
+ +
+

{config.apiKeyLabel}

+

{config.apiKeyDescription}

+
+
+ {getApiKeyStatusBadge()} +
+
+ +
+
+ + setApiKey(e.target.value)} + className="bg-input border-border text-foreground" + data-testid={config.testIds.apiKeyInput} + /> +

+ {config.apiKeyHelpText}{' '} + + {config.apiKeyDocsLabel} + + +

+
+ +
+ + {hasApiKey && ( + + )} +
+
+ + {apiKeyVerificationStatus === 'verifying' && ( +
+ +
+

Verifying API key...

+

Running a test query

+
+
+ )} + + {apiKeyVerificationStatus === 'verified' && ( +
+ +
+

API Key verified!

+

+ Your API key is working correctly. +

+
+
+ )} + + {apiKeyVerificationStatus === 'error' && apiKeyVerificationError && ( +
+ +
+

Verification failed

+ {(() => { + const parts = apiKeyVerificationError.split('\n\nDetails: '); + const mainError = parts[0]; + const details = parts[1]; + + return ( + <> +

{mainError}

+ {details && ( +
+

+ Technical details: +

+
+                                  {details}
+                                
+
+ )} + + ); + })()} +
+
+ )} + + {apiKeyVerificationStatus !== 'verified' && ( + + )} +
+
+
+
+
+ +
+ +
+ + +
+
+
+ ); +} 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 new file mode 100644 index 00000000..ac8352d4 --- /dev/null +++ b/apps/ui/src/components/views/setup-view/steps/codex-setup-step.tsx @@ -0,0 +1,102 @@ +import { useMemo, useCallback } from 'react'; +import { useSetupStore } from '@/store/setup-store'; +import { getElectronAPI } from '@/lib/electron'; +import { CliSetupStep } from './cli-setup-step'; +import type { CodexAuthStatus } from '@/store/setup-store'; + +interface CodexSetupStepProps { + onNext: () => void; + onBack: () => void; + onSkip: () => void; +} + +export function CodexSetupStep({ onNext, onBack, onSkip }: CodexSetupStepProps) { + const { + codexCliStatus, + codexAuthStatus, + setCodexCliStatus, + setCodexAuthStatus, + setCodexInstallProgress, + } = useSetupStore(); + + const statusApi = useCallback( + () => getElectronAPI().setup?.getCodexStatus() || Promise.reject(), + [] + ); + + const installApi = useCallback( + () => getElectronAPI().setup?.installCodex() || Promise.reject(), + [] + ); + + const verifyAuthApi = useCallback( + (method: 'cli' | 'api_key') => + getElectronAPI().setup?.verifyCodexAuth(method) || Promise.reject(), + [] + ); + + const config = useMemo( + () => ({ + cliType: 'codex' as const, + displayName: 'Codex', + cliLabel: 'Codex CLI', + cliDescription: 'Use Codex CLI login', + apiKeyLabel: 'OpenAI API Key', + apiKeyDescription: 'Optional API key for Codex', + apiKeyProvider: 'openai' as const, + apiKeyPlaceholder: 'sk-...', + apiKeyDocsUrl: 'https://platform.openai.com/api-keys', + apiKeyDocsLabel: 'Get one from OpenAI', + apiKeyHelpText: "Don't have an API key?", + installCommands: { + macos: 'npm install -g @openai/codex', + windows: 'npm install -g @openai/codex', + }, + cliLoginCommand: 'codex login', + testIds: { + installButton: 'install-codex-button', + verifyCliButton: 'verify-codex-cli-button', + verifyApiKeyButton: 'verify-codex-api-key-button', + apiKeyInput: 'openai-api-key-input', + saveApiKeyButton: 'save-openai-key-button', + deleteApiKeyButton: 'delete-openai-key-button', + nextButton: 'codex-next-button', + }, + buildCliAuthStatus: (_previous: CodexAuthStatus | null) => ({ + authenticated: true, + method: 'cli_authenticated', + hasAuthFile: true, + }), + buildApiKeyAuthStatus: (_previous: CodexAuthStatus | null) => ({ + authenticated: true, + method: 'api_key', + hasApiKey: true, + }), + buildClearedAuthStatus: (_previous: CodexAuthStatus | null) => ({ + authenticated: false, + method: 'none', + }), + statusApi, + installApi, + verifyAuthApi, + }), + [installApi, statusApi, verifyAuthApi] + ); + + return ( + useSetupStore.getState().codexCliStatus, + }} + onNext={onNext} + onBack={onBack} + onSkip={onSkip} + /> + ); +} diff --git a/apps/ui/src/components/views/setup-view/steps/index.ts b/apps/ui/src/components/views/setup-view/steps/index.ts index 8293eda1..73e2de56 100644 --- a/apps/ui/src/components/views/setup-view/steps/index.ts +++ b/apps/ui/src/components/views/setup-view/steps/index.ts @@ -4,4 +4,5 @@ export { ThemeStep } from './theme-step'; export { CompleteStep } from './complete-step'; export { ClaudeSetupStep } from './claude-setup-step'; export { CursorSetupStep } from './cursor-setup-step'; +export { CodexSetupStep } from './codex-setup-step'; export { GitHubSetupStep } from './github-setup-step'; diff --git a/apps/ui/src/hooks/use-settings-migration.ts b/apps/ui/src/hooks/use-settings-migration.ts index 3674036b..e452c27f 100644 --- a/apps/ui/src/hooks/use-settings-migration.ts +++ b/apps/ui/src/hooks/use-settings-migration.ts @@ -231,6 +231,13 @@ export async function syncSettingsToServer(): Promise { autoLoadClaudeMd: state.autoLoadClaudeMd, enableSandboxMode: state.enableSandboxMode, skipSandboxWarning: state.skipSandboxWarning, + codexAutoLoadAgents: state.codexAutoLoadAgents, + codexSandboxMode: state.codexSandboxMode, + codexApprovalPolicy: state.codexApprovalPolicy, + codexEnableWebSearch: state.codexEnableWebSearch, + codexEnableImages: state.codexEnableImages, + codexAdditionalDirs: state.codexAdditionalDirs, + codexThreadId: state.codexThreadId, keyboardShortcuts: state.keyboardShortcuts, aiProfiles: state.aiProfiles, mcpServers: state.mcpServers, diff --git a/apps/ui/src/lib/agent-context-parser.ts b/apps/ui/src/lib/agent-context-parser.ts index 40244b18..2fe66238 100644 --- a/apps/ui/src/lib/agent-context-parser.ts +++ b/apps/ui/src/lib/agent-context-parser.ts @@ -33,9 +33,31 @@ export const DEFAULT_MODEL = 'claude-opus-4-5-20251101'; * Formats a model name for display */ export function formatModelName(model: string): string { + // Claude models if (model.includes('opus')) return 'Opus 4.5'; if (model.includes('sonnet')) return 'Sonnet 4.5'; if (model.includes('haiku')) return 'Haiku 4.5'; + + // Codex/GPT models + if (model === 'gpt-5.2') return 'GPT-5.2'; + if (model === 'gpt-5.1-codex-max') return 'GPT-5.1 Max'; + if (model === 'gpt-5.1-codex') return 'GPT-5.1 Codex'; + if (model === 'gpt-5.1-codex-mini') return 'GPT-5.1 Mini'; + if (model === 'gpt-5.1') return 'GPT-5.1'; + if (model.startsWith('gpt-')) return model.toUpperCase(); + if (model.match(/^o\d/)) return model.toUpperCase(); // o1, o3, etc. + + // Cursor models + if (model === 'cursor-auto' || model === 'auto') return 'Cursor Auto'; + if (model === 'cursor-composer-1' || model === 'composer-1') return 'Composer 1'; + if (model.startsWith('cursor-sonnet')) return 'Cursor Sonnet'; + if (model.startsWith('cursor-opus')) return 'Cursor Opus'; + if (model.startsWith('cursor-gpt')) return model.replace('cursor-', '').replace('gpt-', 'GPT-'); + if (model.startsWith('cursor-gemini')) + return model.replace('cursor-', 'Cursor ').replace('gemini', 'Gemini'); + if (model.startsWith('cursor-grok')) return 'Cursor Grok'; + + // Default: split by dash and capitalize return model.split('-').slice(1, 3).join(' '); } diff --git a/apps/ui/src/lib/codex-usage-format.ts b/apps/ui/src/lib/codex-usage-format.ts new file mode 100644 index 00000000..288898b2 --- /dev/null +++ b/apps/ui/src/lib/codex-usage-format.ts @@ -0,0 +1,86 @@ +import { type CodexCreditsSnapshot, type CodexPlanType } from '@/store/app-store'; + +const WINDOW_DEFAULT_LABEL = 'Usage window'; +const RESET_LABEL = 'Resets'; +const UNKNOWN_LABEL = 'Unknown'; +const UNAVAILABLE_LABEL = 'Unavailable'; +const UNLIMITED_LABEL = 'Unlimited'; +const AVAILABLE_LABEL = 'Available'; +const NONE_LABEL = 'None'; +const DAY_UNIT = 'day'; +const HOUR_UNIT = 'hour'; +const MINUTE_UNIT = 'min'; +const WINDOW_SUFFIX = 'window'; +const MINUTES_PER_HOUR = 60; +const MINUTES_PER_DAY = 24 * MINUTES_PER_HOUR; +const MILLISECONDS_PER_SECOND = 1000; +const SESSION_HOURS = 5; +const DAYS_PER_WEEK = 7; +const SESSION_WINDOW_MINS = SESSION_HOURS * MINUTES_PER_HOUR; +const WEEKLY_WINDOW_MINS = DAYS_PER_WEEK * MINUTES_PER_DAY; +const SESSION_TITLE = 'Session Usage'; +const SESSION_SUBTITLE = '5-hour rolling window'; +const WEEKLY_TITLE = 'Weekly'; +const WEEKLY_SUBTITLE = 'All models'; +const FALLBACK_TITLE = 'Usage Window'; +const PLAN_TYPE_LABELS: Record = { + free: 'Free', + plus: 'Plus', + pro: 'Pro', + team: 'Team', + business: 'Business', + enterprise: 'Enterprise', + edu: 'Education', + unknown: UNKNOWN_LABEL, +}; + +export function formatCodexWindowDuration(minutes: number | null): string { + if (!minutes || minutes <= 0) return WINDOW_DEFAULT_LABEL; + if (minutes % MINUTES_PER_DAY === 0) { + const days = minutes / MINUTES_PER_DAY; + return `${days} ${DAY_UNIT}${days === 1 ? '' : 's'} ${WINDOW_SUFFIX}`; + } + if (minutes % MINUTES_PER_HOUR === 0) { + const hours = minutes / MINUTES_PER_HOUR; + return `${hours} ${HOUR_UNIT}${hours === 1 ? '' : 's'} ${WINDOW_SUFFIX}`; + } + return `${minutes} ${MINUTE_UNIT} ${WINDOW_SUFFIX}`; +} + +export type CodexWindowLabel = { + title: string; + subtitle: string; + isPrimary: boolean; +}; + +export function getCodexWindowLabel(windowDurationMins: number | null): CodexWindowLabel { + if (windowDurationMins === SESSION_WINDOW_MINS) { + return { title: SESSION_TITLE, subtitle: SESSION_SUBTITLE, isPrimary: true }; + } + if (windowDurationMins === WEEKLY_WINDOW_MINS) { + return { title: WEEKLY_TITLE, subtitle: WEEKLY_SUBTITLE, isPrimary: false }; + } + return { + title: FALLBACK_TITLE, + subtitle: formatCodexWindowDuration(windowDurationMins), + isPrimary: false, + }; +} + +export function formatCodexResetTime(resetsAt: number | null): string | null { + if (!resetsAt) return null; + const date = new Date(resetsAt * MILLISECONDS_PER_SECOND); + return `${RESET_LABEL} ${date.toLocaleString()}`; +} + +export function formatCodexPlanType(plan: CodexPlanType | null): string { + if (!plan) return UNKNOWN_LABEL; + return PLAN_TYPE_LABELS[plan] ?? plan; +} + +export function formatCodexCredits(snapshot: CodexCreditsSnapshot | null): string { + if (!snapshot) return UNAVAILABLE_LABEL; + if (snapshot.unlimited) return UNLIMITED_LABEL; + if (snapshot.balance) return snapshot.balance; + return snapshot.hasCredits ? AVAILABLE_LABEL : NONE_LABEL; +} diff --git a/apps/ui/src/lib/electron.ts b/apps/ui/src/lib/electron.ts index d81b46b6..5ad39b40 100644 --- a/apps/ui/src/lib/electron.ts +++ b/apps/ui/src/lib/electron.ts @@ -682,6 +682,51 @@ export interface ElectronAPI { user: string | null; error?: string; }>; + getCursorStatus: () => Promise<{ + success: boolean; + installed: boolean; + version: string | null; + path: string | null; + auth: { + authenticated: boolean; + method: string; + }; + installCommand?: string; + loginCommand?: string; + error?: string; + }>; + getCodexStatus: () => Promise<{ + success: boolean; + installed: boolean; + version: string | null; + path: string | null; + auth: { + authenticated: boolean; + method: string; + hasApiKey: boolean; + }; + installCommand?: string; + loginCommand?: string; + error?: string; + }>; + installCodex: () => Promise<{ + success: boolean; + message?: string; + error?: string; + }>; + authCodex: () => Promise<{ + success: boolean; + requiresManualAuth?: boolean; + command?: string; + error?: string; + message?: string; + }>; + verifyCodexAuth: (authMethod?: 'cli' | 'api_key') => Promise<{ + success: boolean; + authenticated: boolean; + error?: string; + details?: string; + }>; onInstallProgress?: (callback: (progress: any) => void) => () => void; onAuthProgress?: (callback: (progress: any) => void) => () => void; }; diff --git a/apps/ui/src/lib/http-api-client.ts b/apps/ui/src/lib/http-api-client.ts index 9bf58d8e..0d401bbf 100644 --- a/apps/ui/src/lib/http-api-client.ts +++ b/apps/ui/src/lib/http-api-client.ts @@ -1180,6 +1180,51 @@ export class HttpApiClient implements ElectronAPI { `/api/setup/cursor-permissions/example${profileId ? `?profileId=${profileId}` : ''}` ), + // Codex CLI methods + getCodexStatus: (): Promise<{ + success: boolean; + status?: string; + installed?: boolean; + method?: string; + version?: string; + path?: string; + auth?: { + authenticated: boolean; + method: string; + hasAuthFile?: boolean; + hasOAuthToken?: boolean; + hasApiKey?: boolean; + hasStoredApiKey?: boolean; + hasEnvApiKey?: boolean; + }; + error?: string; + }> => this.get('/api/setup/codex-status'), + + installCodex: (): Promise<{ + success: boolean; + message?: string; + error?: string; + }> => this.post('/api/setup/install-codex'), + + authCodex: (): Promise<{ + success: boolean; + token?: string; + requiresManualAuth?: boolean; + terminalOpened?: boolean; + command?: string; + error?: string; + message?: string; + output?: string; + }> => this.post('/api/setup/auth-codex'), + + verifyCodexAuth: ( + authMethod?: 'cli' | 'api_key' + ): Promise<{ + success: boolean; + authenticated: boolean; + error?: string; + }> => this.post('/api/setup/verify-codex-auth', { authMethod }), + onInstallProgress: (callback: (progress: unknown) => void) => { return this.subscribeToEvent('agent:stream', callback); }, diff --git a/apps/ui/src/lib/utils.ts b/apps/ui/src/lib/utils.ts index 7b2d953c..a26772a6 100644 --- a/apps/ui/src/lib/utils.ts +++ b/apps/ui/src/lib/utils.ts @@ -1,6 +1,6 @@ import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; -import type { ModelAlias } from '@/store/app-store'; +import type { ModelAlias, ModelProvider } from '@/store/app-store'; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -14,6 +14,33 @@ export function modelSupportsThinking(_model?: ModelAlias | string): boolean { return true; } +/** + * Determine the provider from a model string + * Mirrors the logic in apps/server/src/providers/provider-factory.ts + */ +export function getProviderFromModel(model?: string): ModelProvider { + if (!model) return 'claude'; + + // Check for Cursor models (cursor- prefix) + if (model.startsWith('cursor-') || model.startsWith('cursor:')) { + return 'cursor'; + } + + // Check for Codex/OpenAI models (gpt- prefix or o-series) + const CODEX_MODEL_PREFIXES = ['gpt-']; + const OPENAI_O_SERIES_PATTERN = /^o\d/; + if ( + CODEX_MODEL_PREFIXES.some((prefix) => model.startsWith(prefix)) || + OPENAI_O_SERIES_PATTERN.test(model) || + model.startsWith('codex:') + ) { + return 'codex'; + } + + // Default to Claude + return 'claude'; +} + /** * Get display name for a model */ @@ -22,6 +49,15 @@ export function getModelDisplayName(model: ModelAlias | string): string { haiku: 'Claude Haiku', sonnet: 'Claude Sonnet', opus: 'Claude Opus', + // Codex models + 'gpt-5.2': 'GPT-5.2', + 'gpt-5.1-codex-max': 'GPT-5.1 Codex Max', + 'gpt-5.1-codex': 'GPT-5.1 Codex', + 'gpt-5.1-codex-mini': 'GPT-5.1 Codex Mini', + 'gpt-5.1': 'GPT-5.1', + // Cursor models (common ones) + 'cursor-auto': 'Cursor Auto', + 'cursor-composer-1': 'Composer 1', }; return displayNames[model] || model; } diff --git a/apps/ui/src/store/setup-store.ts b/apps/ui/src/store/setup-store.ts index 7a271ed5..b1d1fe47 100644 --- a/apps/ui/src/store/setup-store.ts +++ b/apps/ui/src/store/setup-store.ts @@ -34,6 +34,37 @@ export interface CursorCliStatus { error?: string; } +// Codex CLI Status +export interface CodexCliStatus { + installed: boolean; + version?: string | null; + path?: string | null; + auth?: { + authenticated: boolean; + method: string; + }; + installCommand?: string; + loginCommand?: string; + error?: string; +} + +// Codex Auth Method +export type CodexAuthMethod = + | 'api_key_env' // OPENAI_API_KEY environment variable + | 'api_key' // Manually stored API key + | 'cli_authenticated' // Codex CLI is installed and authenticated + | 'none'; + +// Codex Auth Status +export interface CodexAuthStatus { + authenticated: boolean; + method: CodexAuthMethod; + hasAuthFile?: boolean; + hasApiKey?: boolean; + hasEnvApiKey?: boolean; + error?: string; +} + // Claude Auth Method - all possible authentication sources export type ClaudeAuthMethod = | 'oauth_token_env' @@ -71,6 +102,7 @@ export type SetupStep = | 'claude_detect' | 'claude_auth' | 'cursor' + | 'codex' | 'github' | 'complete'; @@ -91,6 +123,11 @@ export interface SetupState { // Cursor CLI state cursorCliStatus: CursorCliStatus | null; + // Codex CLI state + codexCliStatus: CodexCliStatus | null; + codexAuthStatus: CodexAuthStatus | null; + codexInstallProgress: InstallProgress; + // Setup preferences skipClaudeSetup: boolean; } @@ -115,6 +152,12 @@ export interface SetupActions { // Cursor CLI setCursorCliStatus: (status: CursorCliStatus | null) => void; + // Codex CLI + setCodexCliStatus: (status: CodexCliStatus | null) => void; + setCodexAuthStatus: (status: CodexAuthStatus | null) => void; + setCodexInstallProgress: (progress: Partial) => void; + resetCodexInstallProgress: () => void; + // Preferences setSkipClaudeSetup: (skip: boolean) => void; } @@ -141,6 +184,10 @@ const initialState: SetupState = { ghCliStatus: null, cursorCliStatus: null, + codexCliStatus: null, + codexAuthStatus: null, + codexInstallProgress: { ...initialInstallProgress }, + skipClaudeSetup: shouldSkipSetup, }; @@ -192,6 +239,24 @@ export const useSetupStore = create()( // Cursor CLI setCursorCliStatus: (status) => set({ cursorCliStatus: status }), + // Codex CLI + setCodexCliStatus: (status) => set({ codexCliStatus: status }), + + setCodexAuthStatus: (status) => set({ codexAuthStatus: status }), + + setCodexInstallProgress: (progress) => + set({ + codexInstallProgress: { + ...get().codexInstallProgress, + ...progress, + }, + }), + + resetCodexInstallProgress: () => + set({ + codexInstallProgress: { ...initialInstallProgress }, + }), + // Preferences setSkipClaudeSetup: (skip) => set({ skipClaudeSetup: skip }), }), diff --git a/libs/model-resolver/src/resolver.ts b/libs/model-resolver/src/resolver.ts index b77eb9cb..1b611d68 100644 --- a/libs/model-resolver/src/resolver.ts +++ b/libs/model-resolver/src/resolver.ts @@ -11,6 +11,7 @@ import { CLAUDE_MODEL_MAP, CURSOR_MODEL_MAP, + CODEX_MODEL_MAP, DEFAULT_MODELS, PROVIDER_PREFIXES, isCursorModel, @@ -19,6 +20,10 @@ import { type ThinkingLevel, } from '@automaker/types'; +// Pattern definitions for Codex/OpenAI models +const CODEX_MODEL_PREFIXES = ['gpt-']; +const OPENAI_O_SERIES_PATTERN = /^o\d/; + /** * Resolve a model key/alias to a full model string * @@ -56,16 +61,6 @@ export function resolveModelString( return modelKey; } - // Check if it's a bare Cursor model ID (e.g., "composer-1", "auto", "gpt-4o") - if (modelKey in CURSOR_MODEL_MAP) { - // Return with cursor- prefix so provider routing works correctly - const prefixedModel = `${PROVIDER_PREFIXES.cursor}${modelKey}`; - console.log( - `[ModelResolver] Detected bare Cursor model ID: "${modelKey}" -> "${prefixedModel}"` - ); - return prefixedModel; - } - // Full Claude model string - pass through unchanged if (modelKey.includes('claude-')) { console.log(`[ModelResolver] Using full Claude model string: ${modelKey}`); @@ -79,6 +74,27 @@ export function resolveModelString( return resolved; } + // OpenAI/Codex models - check BEFORE bare Cursor models since they overlap + // (Cursor supports gpt models, but bare "gpt-*" should route to Codex) + if ( + CODEX_MODEL_PREFIXES.some((prefix) => modelKey.startsWith(prefix)) || + OPENAI_O_SERIES_PATTERN.test(modelKey) + ) { + console.log(`[ModelResolver] Using OpenAI/Codex model: ${modelKey}`); + return modelKey; + } + + // Check if it's a bare Cursor model ID (e.g., "composer-1", "auto", "gpt-4o") + // Note: This is checked AFTER Codex check to prioritize Codex for bare gpt-* models + if (modelKey in CURSOR_MODEL_MAP) { + // Return with cursor- prefix so provider routing works correctly + const prefixedModel = `${PROVIDER_PREFIXES.cursor}${modelKey}`; + console.log( + `[ModelResolver] Detected bare Cursor model ID: "${modelKey}" -> "${prefixedModel}"` + ); + return prefixedModel; + } + // Unknown model key - use default console.warn(`[ModelResolver] Unknown model key "${modelKey}", using default: "${defaultModel}"`); return defaultModel; diff --git a/libs/model-resolver/tests/resolver.test.ts b/libs/model-resolver/tests/resolver.test.ts index 459fa7df..04452f83 100644 --- a/libs/model-resolver/tests/resolver.test.ts +++ b/libs/model-resolver/tests/resolver.test.ts @@ -180,7 +180,7 @@ describe('model-resolver', () => { it('should use custom default for unknown model key', () => { const customDefault = 'claude-opus-4-20241113'; - const result = resolveModelString('gpt-4', customDefault); + const result = resolveModelString('truly-unknown-model', customDefault); expect(result).toBe(customDefault); }); diff --git a/libs/platform/src/index.ts b/libs/platform/src/index.ts index 4c51ed3f..9d24ed23 100644 --- a/libs/platform/src/index.ts +++ b/libs/platform/src/index.ts @@ -93,6 +93,9 @@ export { getClaudeSettingsPath, getClaudeStatsCachePath, getClaudeProjectsDir, + getCodexCliPaths, + getCodexConfigDir, + getCodexAuthPath, getShellPaths, getExtendedPath, // Node.js paths @@ -120,6 +123,9 @@ export { findClaudeCliPath, getClaudeAuthIndicators, type ClaudeAuthIndicators, + findCodexCliPath, + getCodexAuthIndicators, + type CodexAuthIndicators, // Electron userData operations setElectronUserDataPath, getElectronUserDataPath, diff --git a/libs/platform/src/system-paths.ts b/libs/platform/src/system-paths.ts index 6011e559..ccf51986 100644 --- a/libs/platform/src/system-paths.ts +++ b/libs/platform/src/system-paths.ts @@ -71,6 +71,49 @@ export function getClaudeCliPaths(): string[] { ]; } +/** + * Get common paths where Codex CLI might be installed + */ +export function getCodexCliPaths(): string[] { + const isWindows = process.platform === 'win32'; + + if (isWindows) { + const appData = process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'); + return [ + path.join(os.homedir(), '.local', 'bin', 'codex.exe'), + path.join(appData, 'npm', 'codex.cmd'), + path.join(appData, 'npm', 'codex'), + path.join(appData, '.npm-global', 'bin', 'codex.cmd'), + path.join(appData, '.npm-global', 'bin', 'codex'), + ]; + } + + return [ + path.join(os.homedir(), '.local', 'bin', 'codex'), + '/opt/homebrew/bin/codex', + '/usr/local/bin/codex', + path.join(os.homedir(), '.npm-global', 'bin', 'codex'), + ]; +} + +const CODEX_CONFIG_DIR_NAME = '.codex'; +const CODEX_AUTH_FILENAME = 'auth.json'; +const CODEX_TOKENS_KEY = 'tokens'; + +/** + * Get the Codex configuration directory path + */ +export function getCodexConfigDir(): string { + return path.join(os.homedir(), CODEX_CONFIG_DIR_NAME); +} + +/** + * Get path to Codex auth file + */ +export function getCodexAuthPath(): string { + return path.join(getCodexConfigDir(), CODEX_AUTH_FILENAME); +} + /** * Get the Claude configuration directory path */ @@ -413,6 +456,11 @@ function getAllAllowedSystemPaths(): string[] { getClaudeSettingsPath(), getClaudeStatsCachePath(), getClaudeProjectsDir(), + // Codex CLI paths + ...getCodexCliPaths(), + // Codex config directory and files + getCodexConfigDir(), + getCodexAuthPath(), // Shell paths ...getShellPaths(), // Node.js system paths @@ -432,6 +480,8 @@ function getAllAllowedSystemDirs(): string[] { // Claude config getClaudeConfigDir(), getClaudeProjectsDir(), + // Codex config + getCodexConfigDir(), // Version managers (need recursive access for version directories) ...getNvmPaths(), ...getFnmPaths(), @@ -740,6 +790,10 @@ export async function findClaudeCliPath(): Promise { return findFirstExistingPath(getClaudeCliPaths()); } +export async function findCodexCliPath(): Promise { + return findFirstExistingPath(getCodexCliPaths()); +} + /** * Get Claude authentication status by checking various indicators */ @@ -818,3 +872,56 @@ export async function getClaudeAuthIndicators(): Promise { return result; } + +export interface CodexAuthIndicators { + hasAuthFile: boolean; + hasOAuthToken: boolean; + hasApiKey: boolean; +} + +const CODEX_OAUTH_KEYS = ['access_token', 'oauth_token'] as const; +const CODEX_API_KEY_KEYS = ['api_key', 'OPENAI_API_KEY'] as const; + +function hasNonEmptyStringField(record: Record, keys: readonly string[]): boolean { + return keys.some((key) => typeof record[key] === 'string' && record[key]); +} + +function getNestedTokens(record: Record): Record | null { + const tokens = record[CODEX_TOKENS_KEY]; + if (tokens && typeof tokens === 'object' && !Array.isArray(tokens)) { + return tokens as Record; + } + return null; +} + +export async function getCodexAuthIndicators(): Promise { + const result: CodexAuthIndicators = { + hasAuthFile: false, + hasOAuthToken: false, + hasApiKey: false, + }; + + try { + const authContent = await systemPathReadFile(getCodexAuthPath()); + result.hasAuthFile = true; + + try { + const authJson = JSON.parse(authContent) as Record; + result.hasOAuthToken = hasNonEmptyStringField(authJson, CODEX_OAUTH_KEYS); + result.hasApiKey = hasNonEmptyStringField(authJson, CODEX_API_KEY_KEYS); + const nestedTokens = getNestedTokens(authJson); + if (nestedTokens) { + result.hasOAuthToken = + result.hasOAuthToken || hasNonEmptyStringField(nestedTokens, CODEX_OAUTH_KEYS); + result.hasApiKey = + result.hasApiKey || hasNonEmptyStringField(nestedTokens, CODEX_API_KEY_KEYS); + } + } catch { + // Ignore parse errors; file exists but contents are unreadable + } + } catch { + // Auth file not found or inaccessible + } + + return result; +} diff --git a/libs/types/src/codex.ts b/libs/types/src/codex.ts new file mode 100644 index 00000000..388e5890 --- /dev/null +++ b/libs/types/src/codex.ts @@ -0,0 +1,44 @@ +/** Sandbox modes for Codex CLI command execution */ +export type CodexSandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access'; + +/** Approval policies for Codex CLI tool execution */ +export type CodexApprovalPolicy = 'untrusted' | 'on-failure' | 'on-request' | 'never'; + +/** Codex event types emitted by CLI */ +export type CodexEventType = + | 'thread.started' + | 'turn.started' + | 'turn.completed' + | 'turn.failed' + | 'item.completed' + | 'error'; + +/** Codex item types in CLI events */ +export type CodexItemType = + | 'agent_message' + | 'reasoning' + | 'command_execution' + | 'file_change' + | 'mcp_tool_call' + | 'web_search' + | 'plan_update'; + +/** Codex CLI event structure */ +export interface CodexEvent { + type: CodexEventType; + thread_id?: string; + item?: { + type: CodexItemType; + content?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +/** Codex CLI configuration (stored in .automaker/codex-config.json) */ +export interface CodexCliConfig { + /** Default model to use when not specified */ + defaultModel?: string; + /** List of enabled models */ + models?: string[]; +} diff --git a/libs/types/src/index.ts b/libs/types/src/index.ts index 57784b2a..a48cc76d 100644 --- a/libs/types/src/index.ts +++ b/libs/types/src/index.ts @@ -17,8 +17,12 @@ export type { McpStdioServerConfig, McpSSEServerConfig, McpHttpServerConfig, + ReasoningEffort, } from './provider.js'; +// Codex CLI types +export type { CodexSandboxMode, CodexApprovalPolicy, CodexCliConfig } from './codex.js'; + // Feature types export type { Feature, FeatureImagePath, FeatureTextFilePath, FeatureStatus } from './feature.js'; @@ -37,7 +41,18 @@ export type { ErrorType, ErrorInfo } from './error.js'; export type { ImageData, ImageContentBlock } from './image.js'; // Model types and constants -export { CLAUDE_MODEL_MAP, DEFAULT_MODELS, type ModelAlias } from './model.js'; +export { + CLAUDE_MODEL_MAP, + CODEX_MODEL_MAP, + CODEX_MODEL_IDS, + REASONING_CAPABLE_MODELS, + supportsReasoningEffort, + getAllCodexModelIds, + DEFAULT_MODELS, + type ModelAlias, + type CodexModelId, + type AgentModel, +} from './model.js'; // Event types export type { EventType, EventCallback } from './event.js'; @@ -103,11 +118,13 @@ export { } from './settings.js'; // Model display constants -export type { ModelOption, ThinkingLevelOption } from './model-display.js'; +export type { ModelOption, ThinkingLevelOption, ReasoningEffortOption } from './model-display.js'; export { CLAUDE_MODELS, THINKING_LEVELS, THINKING_LEVEL_LABELS, + REASONING_EFFORT_LEVELS, + REASONING_EFFORT_LABELS, getModelDisplayName, } from './model-display.js'; @@ -150,6 +167,7 @@ export { PROVIDER_PREFIXES, isCursorModel, isClaudeModel, + isCodexModel, getModelProvider, stripProviderPrefix, addProviderPrefix, diff --git a/libs/types/src/model-display.ts b/libs/types/src/model-display.ts index cc75b0eb..6e79b592 100644 --- a/libs/types/src/model-display.ts +++ b/libs/types/src/model-display.ts @@ -6,7 +6,10 @@ */ import type { ModelAlias, ThinkingLevel, ModelProvider } from './settings.js'; +import type { ReasoningEffort } from './provider.js'; import type { CursorModelId } from './cursor-models.js'; +import type { AgentModel, CodexModelId } from './model.js'; +import { CODEX_MODEL_MAP } from './model.js'; /** * ModelOption - Display metadata for a model option in the UI @@ -63,6 +66,61 @@ export const CLAUDE_MODELS: ModelOption[] = [ }, ]; +/** + * Codex model options with full metadata for UI display + * Official models from https://developers.openai.com/codex/models/ + */ +export const CODEX_MODELS: (ModelOption & { hasReasoning?: boolean })[] = [ + { + id: CODEX_MODEL_MAP.gpt52Codex, + label: 'GPT-5.2-Codex', + description: 'Most advanced agentic coding model (default for ChatGPT users).', + badge: 'Premium', + provider: 'codex', + hasReasoning: true, + }, + { + id: CODEX_MODEL_MAP.gpt5Codex, + label: 'GPT-5-Codex', + description: 'Purpose-built for Codex CLI (default for CLI users).', + badge: 'Balanced', + provider: 'codex', + hasReasoning: true, + }, + { + id: CODEX_MODEL_MAP.gpt5CodexMini, + label: 'GPT-5-Codex-Mini', + description: 'Faster workflows for code Q&A and editing.', + badge: 'Speed', + provider: 'codex', + hasReasoning: false, + }, + { + id: CODEX_MODEL_MAP.codex1, + label: 'Codex-1', + description: 'o3-based model optimized for software engineering.', + badge: 'Premium', + provider: 'codex', + hasReasoning: true, + }, + { + id: CODEX_MODEL_MAP.codexMiniLatest, + label: 'Codex-Mini-Latest', + description: 'o4-mini-based model for faster workflows.', + badge: 'Balanced', + provider: 'codex', + hasReasoning: false, + }, + { + id: CODEX_MODEL_MAP.gpt5, + label: 'GPT-5', + description: 'GPT-5 base flagship model.', + badge: 'Balanced', + provider: 'codex', + hasReasoning: true, + }, +]; + /** * Thinking level options with display labels * @@ -89,6 +147,43 @@ export const THINKING_LEVEL_LABELS: Record = { ultrathink: 'Ultra', }; +/** + * ReasoningEffortOption - Display metadata for reasoning effort selection (Codex/OpenAI) + */ +export interface ReasoningEffortOption { + /** Reasoning effort identifier */ + id: ReasoningEffort; + /** Display label */ + label: string; + /** Description of what this level does */ + description: string; +} + +/** + * Reasoning effort options for Codex/OpenAI models + * All models support reasoning effort levels + */ +export const REASONING_EFFORT_LEVELS: ReasoningEffortOption[] = [ + { id: 'none', label: 'None', description: 'No reasoning tokens (GPT-5.1 models only)' }, + { id: 'minimal', label: 'Minimal', description: 'Very quick reasoning' }, + { id: 'low', label: 'Low', description: 'Quick responses for simpler queries' }, + { id: 'medium', label: 'Medium', description: 'Balance between depth and speed (default)' }, + { id: 'high', label: 'High', description: 'Maximizes reasoning depth for critical tasks' }, + { id: 'xhigh', label: 'XHigh', description: 'Highest level for gpt-5.1-codex-max and newer' }, +]; + +/** + * Map of reasoning effort levels to short display labels + */ +export const REASONING_EFFORT_LABELS: Record = { + none: 'None', + minimal: 'Min', + low: 'Low', + medium: 'Med', + high: 'High', + xhigh: 'XHigh', +}; + /** * Get display name for a model * @@ -107,6 +202,12 @@ export function getModelDisplayName(model: ModelAlias | string): string { haiku: 'Claude Haiku', sonnet: 'Claude Sonnet', opus: 'Claude Opus', + [CODEX_MODEL_MAP.gpt52Codex]: 'GPT-5.2-Codex', + [CODEX_MODEL_MAP.gpt5Codex]: 'GPT-5-Codex', + [CODEX_MODEL_MAP.gpt5CodexMini]: 'GPT-5-Codex-Mini', + [CODEX_MODEL_MAP.codex1]: 'Codex-1', + [CODEX_MODEL_MAP.codexMiniLatest]: 'Codex-Mini-Latest', + [CODEX_MODEL_MAP.gpt5]: 'GPT-5', }; return displayNames[model] || model; } diff --git a/libs/types/src/model.ts b/libs/types/src/model.ts index 1468b743..d16fd215 100644 --- a/libs/types/src/model.ts +++ b/libs/types/src/model.ts @@ -7,12 +7,70 @@ export const CLAUDE_MODEL_MAP: Record = { opus: 'claude-opus-4-5-20251101', } as const; +/** + * Codex/OpenAI model identifiers + * Based on OpenAI Codex CLI official models + * See: https://developers.openai.com/codex/models/ + */ +export const CODEX_MODEL_MAP = { + // Codex-specific models + /** Most advanced agentic coding model for complex software engineering (default for ChatGPT users) */ + gpt52Codex: 'gpt-5.2-codex', + /** Purpose-built for Codex CLI with versatile tool use (default for CLI users) */ + gpt5Codex: 'gpt-5-codex', + /** Faster workflows optimized for low-latency code Q&A and editing */ + gpt5CodexMini: 'gpt-5-codex-mini', + /** Version of o3 optimized for software engineering */ + codex1: 'codex-1', + /** Version of o4-mini for Codex, optimized for faster workflows */ + codexMiniLatest: 'codex-mini-latest', + + // Base GPT-5 model (also available in Codex) + /** GPT-5 base flagship model */ + gpt5: 'gpt-5', +} as const; + +export const CODEX_MODEL_IDS = Object.values(CODEX_MODEL_MAP); + +/** + * Models that support reasoning effort configuration + * These models can use reasoning.effort parameter + */ +export const REASONING_CAPABLE_MODELS = new Set([ + CODEX_MODEL_MAP.gpt52Codex, + CODEX_MODEL_MAP.gpt5Codex, + CODEX_MODEL_MAP.gpt5, + CODEX_MODEL_MAP.codex1, // o3-based model +]); + +/** + * Check if a model supports reasoning effort configuration + */ +export function supportsReasoningEffort(modelId: string): boolean { + return REASONING_CAPABLE_MODELS.has(modelId as any); +} + +/** + * Get all Codex model IDs as an array + */ +export function getAllCodexModelIds(): CodexModelId[] { + return CODEX_MODEL_IDS as CodexModelId[]; +} + /** * Default models per provider */ export const DEFAULT_MODELS = { claude: 'claude-opus-4-5-20251101', cursor: 'auto', // Cursor's recommended default + codex: CODEX_MODEL_MAP.gpt52Codex, // GPT-5.2-Codex is the most advanced agentic coding model } as const; export type ModelAlias = keyof typeof CLAUDE_MODEL_MAP; +export type CodexModelId = (typeof CODEX_MODEL_MAP)[keyof typeof CODEX_MODEL_MAP]; + +/** + * AgentModel - Alias for ModelAlias for backward compatibility + * Represents available models across providers + */ +export type AgentModel = ModelAlias | CodexModelId; diff --git a/libs/types/src/provider-utils.ts b/libs/types/src/provider-utils.ts index 20ac3637..51ebb85d 100644 --- a/libs/types/src/provider-utils.ts +++ b/libs/types/src/provider-utils.ts @@ -8,11 +8,12 @@ import type { ModelProvider } from './settings.js'; import { CURSOR_MODEL_MAP, type CursorModelId } from './cursor-models.js'; -import { CLAUDE_MODEL_MAP } from './model.js'; +import { CLAUDE_MODEL_MAP, CODEX_MODEL_MAP, type CodexModelId } from './model.js'; /** Provider prefix constants */ export const PROVIDER_PREFIXES = { cursor: 'cursor-', + codex: 'codex-', // Add new provider prefixes here } as const; @@ -52,6 +53,35 @@ export function isClaudeModel(model: string | undefined | null): boolean { return model.includes('claude-'); } +/** + * Check if a model string represents a Codex/OpenAI model + * + * @param model - Model string to check (e.g., "gpt-5.2", "o1", "codex-gpt-5.2") + * @returns true if the model is a Codex model + */ +export function isCodexModel(model: string | undefined | null): boolean { + if (!model || typeof model !== 'string') return false; + + // Check for explicit codex- prefix + if (model.startsWith(PROVIDER_PREFIXES.codex)) { + return true; + } + + // Check if it's a gpt- model + if (model.startsWith('gpt-')) { + return true; + } + + // Check if it's an o-series model (o1, o3, etc.) + if (/^o\d/.test(model)) { + return true; + } + + // Check if it's in the CODEX_MODEL_MAP + const modelValues = Object.values(CODEX_MODEL_MAP); + return modelValues.includes(model as CodexModelId); +} + /** * Get the provider for a model string * @@ -59,6 +89,11 @@ export function isClaudeModel(model: string | undefined | null): boolean { * @returns The provider type, defaults to 'claude' for unknown models */ export function getModelProvider(model: string | undefined | null): ModelProvider { + // Check Codex first before Cursor, since Cursor also supports gpt models + // but bare gpt-* should route to Codex + if (isCodexModel(model)) { + return 'codex'; + } if (isCursorModel(model)) { return 'cursor'; } @@ -96,6 +131,7 @@ export function stripProviderPrefix(model: string): string { * @example * addProviderPrefix('composer-1', 'cursor') // 'cursor-composer-1' * addProviderPrefix('cursor-composer-1', 'cursor') // 'cursor-composer-1' (no change) + * addProviderPrefix('gpt-5.2', 'codex') // 'codex-gpt-5.2' * addProviderPrefix('sonnet', 'claude') // 'sonnet' (Claude doesn't use prefix) */ export function addProviderPrefix(model: string, provider: ModelProvider): string { @@ -105,6 +141,10 @@ export function addProviderPrefix(model: string, provider: ModelProvider): strin if (!model.startsWith(PROVIDER_PREFIXES.cursor)) { return `${PROVIDER_PREFIXES.cursor}${model}`; } + } else if (provider === 'codex') { + if (!model.startsWith(PROVIDER_PREFIXES.codex)) { + return `${PROVIDER_PREFIXES.codex}${model}`; + } } // Claude models don't use prefixes return model; @@ -123,6 +163,7 @@ export function getBareModelId(model: string): string { /** * Normalize a model string to its canonical form * - For Cursor: adds cursor- prefix if missing + * - For Codex: can add codex- prefix (but bare gpt-* is also valid) * - For Claude: returns as-is * * @param model - Model string to normalize @@ -136,5 +177,19 @@ export function normalizeModelString(model: string | undefined | null): string { return `${PROVIDER_PREFIXES.cursor}${model}`; } + // For Codex, bare gpt-* and o-series models are valid canonical forms + // Only add prefix if it's in CODEX_MODEL_MAP but doesn't have gpt-/o prefix + const codexModelValues = Object.values(CODEX_MODEL_MAP); + if (codexModelValues.includes(model as CodexModelId)) { + // If it already starts with gpt- or o, it's canonical + if (model.startsWith('gpt-') || /^o\d/.test(model)) { + return model; + } + // Otherwise, it might need a prefix (though this is unlikely) + if (!model.startsWith(PROVIDER_PREFIXES.codex)) { + return `${PROVIDER_PREFIXES.codex}${model}`; + } + } + return model; } diff --git a/libs/types/src/provider.ts b/libs/types/src/provider.ts index 5b3549a6..308d2b82 100644 --- a/libs/types/src/provider.ts +++ b/libs/types/src/provider.ts @@ -3,6 +3,20 @@ */ import type { ThinkingLevel } from './settings.js'; +import type { CodexSandboxMode, CodexApprovalPolicy } from './codex.js'; + +/** + * Reasoning effort levels for Codex/OpenAI models + * Controls the computational intensity and reasoning tokens used. + * Based on OpenAI API documentation: + * - 'none': No reasoning (GPT-5.1 models only) + * - 'minimal': Very quick reasoning + * - 'low': Quick responses for simpler queries + * - 'medium': Balance between depth and speed (default) + * - 'high': Maximizes reasoning depth for critical tasks + * - 'xhigh': Highest level, supported by gpt-5.1-codex-max and newer + */ +export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'; /** * Configuration for a provider instance @@ -73,6 +87,10 @@ export interface ExecuteOptions { maxTurns?: number; allowedTools?: string[]; mcpServers?: Record; + /** If true, allows all MCP tools unrestricted (no approval needed). Default: false */ + mcpUnrestrictedTools?: boolean; + /** If true, automatically approves all MCP tool calls. Default: undefined (uses approval policy) */ + mcpAutoApproveTools?: boolean; abortController?: AbortController; conversationHistory?: ConversationMessage[]; // Previous messages for context sdkSessionId?: string; // Claude SDK session ID for resuming conversations @@ -90,6 +108,31 @@ export interface ExecuteOptions { * Only applies to Claude models; Cursor models handle thinking internally. */ thinkingLevel?: ThinkingLevel; + /** + * Reasoning effort for Codex/OpenAI models with reasoning capabilities. + * Controls how many reasoning tokens the model generates before responding. + * Supported values: 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' + * - none: No reasoning tokens (fastest) + * - minimal/low: Quick reasoning for simple tasks + * - medium: Balanced reasoning (default) + * - high: Extended reasoning for complex tasks + * - xhigh: Maximum reasoning for quality-critical tasks + * Only applies to models that support reasoning (gpt-5.1-codex-max+, o3-mini, o4-mini) + */ + reasoningEffort?: ReasoningEffort; + codexSettings?: { + autoLoadAgents?: boolean; + sandboxMode?: CodexSandboxMode; + approvalPolicy?: CodexApprovalPolicy; + enableWebSearch?: boolean; + enableImages?: boolean; + additionalDirs?: string[]; + threadId?: string; + }; + outputFormat?: { + type: 'json_schema'; + schema: Record; + }; } /** @@ -166,4 +209,5 @@ export interface ModelDefinition { supportsTools?: boolean; tier?: 'basic' | 'standard' | 'premium'; default?: boolean; + hasReasoning?: boolean; } diff --git a/libs/types/src/settings.ts b/libs/types/src/settings.ts index 0220da3a..5dce3a52 100644 --- a/libs/types/src/settings.ts +++ b/libs/types/src/settings.ts @@ -6,10 +6,11 @@ * (for file I/O via SettingsService) and the UI (for state management and sync). */ -import type { ModelAlias } from './model.js'; +import type { ModelAlias, AgentModel, CodexModelId } from './model.js'; import type { CursorModelId } from './cursor-models.js'; import { CURSOR_MODEL_MAP, getAllCursorModelIds } from './cursor-models.js'; import type { PromptCustomization } from './prompts.js'; +import type { CodexSandboxMode, CodexApprovalPolicy } from './codex.js'; // Re-export ModelAlias for convenience export type { ModelAlias }; @@ -95,7 +96,14 @@ export function getThinkingTokenBudget(level: ThinkingLevel | undefined): number } /** ModelProvider - AI model provider for credentials and API key management */ -export type ModelProvider = 'claude' | 'cursor'; +export type ModelProvider = 'claude' | 'cursor' | 'codex'; + +const DEFAULT_CODEX_AUTO_LOAD_AGENTS = false; +const DEFAULT_CODEX_SANDBOX_MODE: CodexSandboxMode = 'workspace-write'; +const DEFAULT_CODEX_APPROVAL_POLICY: CodexApprovalPolicy = 'on-request'; +const DEFAULT_CODEX_ENABLE_WEB_SEARCH = false; +const DEFAULT_CODEX_ENABLE_IMAGES = true; +const DEFAULT_CODEX_ADDITIONAL_DIRS: string[] = []; /** * PhaseModelEntry - Configuration for a single phase model @@ -227,7 +235,7 @@ export interface AIProfile { name: string; /** User-friendly description */ description: string; - /** Provider selection: 'claude' or 'cursor' */ + /** Provider selection: 'claude', 'cursor', or 'codex' */ provider: ModelProvider; /** Whether this is a built-in default profile */ isBuiltIn: boolean; @@ -245,6 +253,10 @@ export interface AIProfile { * Note: For Cursor, thinking is embedded in the model ID (e.g., 'claude-sonnet-4-thinking') */ cursorModel?: CursorModelId; + + // Codex-specific settings + /** Which Codex/GPT model to use - only for Codex provider */ + codexModel?: CodexModelId; } /** @@ -262,6 +274,12 @@ export function profileHasThinking(profile: AIProfile): boolean { return modelConfig?.hasThinking ?? false; } + if (profile.provider === 'codex') { + // Codex models handle thinking internally (o-series models) + const model = profile.codexModel || 'gpt-5.2'; + return model.startsWith('o'); + } + return false; } @@ -273,6 +291,10 @@ export function getProfileModelString(profile: AIProfile): string { return `cursor:${profile.cursorModel || 'auto'}`; } + if (profile.provider === 'codex') { + return `codex:${profile.codexModel || 'gpt-5.2'}`; + } + // Claude return profile.model || 'sonnet'; } @@ -479,6 +501,22 @@ export interface GlobalSettings { /** Skip showing the sandbox risk warning dialog */ skipSandboxWarning?: boolean; + // Codex CLI Settings + /** Auto-load .codex/AGENTS.md instructions into Codex prompts */ + codexAutoLoadAgents?: boolean; + /** Sandbox mode for Codex CLI command execution */ + codexSandboxMode?: CodexSandboxMode; + /** Approval policy for Codex CLI tool execution */ + codexApprovalPolicy?: CodexApprovalPolicy; + /** Enable web search capability for Codex CLI (--search flag) */ + codexEnableWebSearch?: boolean; + /** Enable image attachment support for Codex CLI (-i flag) */ + codexEnableImages?: boolean; + /** Additional directories with write access (--add-dir flags) */ + codexAdditionalDirs?: string[]; + /** Last thread ID for session resumption */ + codexThreadId?: string; + // MCP Server Configuration /** List of configured MCP servers for agent use */ mcpServers: MCPServerConfig[]; @@ -674,6 +712,13 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = { autoLoadClaudeMd: false, enableSandboxMode: false, skipSandboxWarning: false, + codexAutoLoadAgents: DEFAULT_CODEX_AUTO_LOAD_AGENTS, + codexSandboxMode: DEFAULT_CODEX_SANDBOX_MODE, + codexApprovalPolicy: DEFAULT_CODEX_APPROVAL_POLICY, + codexEnableWebSearch: DEFAULT_CODEX_ENABLE_WEB_SEARCH, + codexEnableImages: DEFAULT_CODEX_ENABLE_IMAGES, + codexAdditionalDirs: DEFAULT_CODEX_ADDITIONAL_DIRS, + codexThreadId: undefined, mcpServers: [], }; diff --git a/package-lock.json b/package-lock.json index b6c486be..376cf074 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "@automaker/types": "1.0.0", "@automaker/utils": "1.0.0", "@modelcontextprotocol/sdk": "1.25.1", + "@openai/codex-sdk": "^0.77.0", "cookie-parser": "1.4.7", "cors": "2.8.5", "dotenv": "17.2.3", @@ -1467,7 +1468,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", @@ -3994,6 +3995,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@openai/codex-sdk": { + "version": "0.77.0", + "resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.77.0.tgz", + "integrity": "sha512-bvJQ4dASnZ7jgfxmseViQwdRupHxs0TwHSZFeYB0gpdOAXnWwDWdGJRCMyphLSHwExRp27JNOk7EBFVmZRBanQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",