mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
feature/codex-cli
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
85
apps/server/src/providers/codex-config-manager.ts
Normal file
85
apps/server/src/providers/codex-config-manager.ts
Normal file
@@ -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, string>): 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<string, McpServerConfig>
|
||||
): Promise<void> {
|
||||
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');
|
||||
}
|
||||
}
|
||||
}
|
||||
123
apps/server/src/providers/codex-models.ts
Normal file
123
apps/server/src/providers/codex-models.ts
Normal file
@@ -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);
|
||||
}
|
||||
987
apps/server/src/providers/codex-provider.ts
Normal file
987
apps/server/src/providers/codex-provider.ts
Normal file
@@ -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<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
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<CodexExecutionPlan> {
|
||||
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, unknown>): 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<string, unknown>;
|
||||
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, unknown>): string | null {
|
||||
const direct = extractText(item.command ?? item.input ?? item.content);
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractCommandOutput(item: Record<string, unknown>): 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, unknown>): 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<string, unknown>;
|
||||
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<string | null> {
|
||||
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<string[]> {
|
||||
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<string, unknown>,
|
||||
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<string, unknown>,
|
||||
item: Record<string, unknown>
|
||||
): string | null {
|
||||
return (
|
||||
getIdentifierFromRecord(item, ITEM_ID_KEYS) ?? getIdentifierFromRecord(event, EVENT_ID_KEYS)
|
||||
);
|
||||
}
|
||||
|
||||
class CodexToolUseTracker {
|
||||
private readonly toolUseIdsByItem = new Map<string, string>();
|
||||
private readonly anonymousToolUses: string[] = [];
|
||||
private sequence = 0;
|
||||
|
||||
register(event: Record<string, unknown>, item: Record<string, unknown>): 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<string, unknown>, item: Record<string, unknown>): 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<CodexCliSettings> {
|
||||
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<string | null> {
|
||||
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<string | null> {
|
||||
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<string>();
|
||||
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<ProviderMessage> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<InstallationStatus> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
173
apps/server/src/providers/codex-sdk-client.ts
Normal file
173
apps/server/src/providers/codex-sdk-client.ts
Normal file
@@ -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<ProviderMessage> {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
385
apps/server/src/providers/codex-tool-mapping.ts
Normal file
385
apps/server/src/providers/codex-tool-mapping.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
export type CodexToolResolution = {
|
||||
name: string;
|
||||
input: Record<string, unknown>;
|
||||
};
|
||||
|
||||
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*(?:\[(?<status>[ x~])\]\s*)?(?<content>.+)$/;
|
||||
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<string, unknown> {
|
||||
return filePath ? { [INPUT_KEY_FILE_PATH]: filePath } : {};
|
||||
}
|
||||
|
||||
function buildInputWithPattern(pattern: string | null): Record<string, unknown> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>): 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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());
|
||||
|
||||
31
apps/server/src/routes/setup/routes/auth-codex.ts
Normal file
31
apps/server/src/routes/setup/routes/auth-codex.ts
Normal file
@@ -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<void> => {
|
||||
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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
43
apps/server/src/routes/setup/routes/codex-status.ts
Normal file
43
apps/server/src/routes/setup/routes/codex-status.ts
Normal file
@@ -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<void> => {
|
||||
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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
33
apps/server/src/routes/setup/routes/install-codex.ts
Normal file
33
apps/server/src/routes/setup/routes/install-codex.ts
Normal file
@@ -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<void> => {
|
||||
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),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
232
apps/server/src/routes/setup/routes/verify-codex-auth.ts
Normal file
232
apps/server/src/routes/setup/routes/verify-codex-auth.ts
Normal file
@@ -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<void> => {
|
||||
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];
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
290
apps/server/tests/unit/providers/codex-provider.test.ts
Normal file
290
apps/server/tests/unit/providers/codex-provider.test.ts
Normal file
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
154
apps/ui/src/components/ui/provider-icon.tsx
Normal file
154
apps/ui/src/components/ui/provider-icon.tsx
Normal file
@@ -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<ProviderIconKey, ProviderIconDefinition> = {
|
||||
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<SVGProps<SVGSVGElement>, '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 (
|
||||
<svg
|
||||
viewBox={definition.viewBox}
|
||||
className={cn('inline-block', className)}
|
||||
role={role ?? (hasAccessibleLabel ? 'img' : 'presentation')}
|
||||
aria-hidden={ariaHidden ?? !hasAccessibleLabel}
|
||||
focusable="false"
|
||||
{...rest}
|
||||
>
|
||||
{title && <title>{title}</title>}
|
||||
<path d={definition.path} fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnthropicIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.anthropic} {...props} />;
|
||||
}
|
||||
|
||||
export function OpenAIIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.openai} {...props} />;
|
||||
}
|
||||
|
||||
export function CursorIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.cursor} {...props} />;
|
||||
}
|
||||
|
||||
export function GeminiIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.gemini} {...props} />;
|
||||
}
|
||||
|
||||
export function GrokIcon(props: Omit<ProviderIconProps, 'provider'>) {
|
||||
return <ProviderIcon provider={PROVIDER_ICON_KEYS.grok} {...props} />;
|
||||
}
|
||||
|
||||
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<ProviderIconKey, ComponentType<{ className?: string }>> = {
|
||||
anthropic: AnthropicIcon,
|
||||
openai: OpenAIIcon,
|
||||
cursor: CursorIcon,
|
||||
gemini: GeminiIcon,
|
||||
grok: GrokIcon,
|
||||
};
|
||||
|
||||
return iconMap[iconKey] || AnthropicIcon;
|
||||
}
|
||||
@@ -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<ThinkingLevel, string> = {
|
||||
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<ReasoningEffort, string> = {
|
||||
none: 'None',
|
||||
minimal: 'Min',
|
||||
low: 'Low',
|
||||
medium: 'Med',
|
||||
high: 'High',
|
||||
xhigh: 'XHigh',
|
||||
};
|
||||
|
||||
// Profile icon mapping
|
||||
export const PROFILE_ICONS: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Brain,
|
||||
|
||||
@@ -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`}
|
||||
>
|
||||
<Bot className="w-4 h-4" />
|
||||
<AnthropicIcon className="w-4 h-4" />
|
||||
Claude
|
||||
</button>
|
||||
<button
|
||||
@@ -76,9 +83,23 @@ export function ModelSelector({
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-cursor`}
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
<CursorIcon className="w-4 h-4" />
|
||||
Cursor CLI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('codex')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
selectedProvider === 'codex'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-provider-codex`}
|
||||
>
|
||||
<OpenAIIcon className="w-4 h-4" />
|
||||
Codex CLI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -136,7 +157,7 @@ export function ModelSelector({
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4 text-primary" />
|
||||
<CursorIcon className="w-4 h-4 text-primary" />
|
||||
Cursor Model
|
||||
</Label>
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-full border border-amber-500/40 text-amber-600 dark:text-amber-400">
|
||||
@@ -188,6 +209,67 @@ export function ModelSelector({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Codex Models */}
|
||||
{selectedProvider === 'codex' && (
|
||||
<div className="space-y-3">
|
||||
{/* Warning when Codex CLI is not available */}
|
||||
{!isCodexAvailable && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-400 mt-0.5 shrink-0" />
|
||||
<div className="text-sm text-amber-400">
|
||||
Codex CLI is not installed or authenticated. Configure it in Settings → AI
|
||||
Providers.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="flex items-center gap-2">
|
||||
<OpenAIIcon className="w-4 h-4 text-primary" />
|
||||
Codex Model
|
||||
</Label>
|
||||
<span className="text-[11px] px-2 py-0.5 rounded-full border border-emerald-500/40 text-emerald-600 dark:text-emerald-400">
|
||||
CLI
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{CODEX_MODELS.map((option) => {
|
||||
const isSelected = selectedModel === option.id;
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
onClick={() => onModelSelect(option.id)}
|
||||
title={option.description}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
|
||||
isSelected
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid={`${testIdPrefix}-${option.id}`}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
{option.badge && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
isSelected
|
||||
? 'border-primary-foreground/50 text-primary-foreground'
|
||||
: 'border-muted-foreground/50 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{option.badge}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 */}
|
||||
<div className="space-y-2">
|
||||
<Label>AI Provider</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('claude')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
'px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
formData.provider === 'claude'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid="provider-select-claude"
|
||||
>
|
||||
<Bot className="w-4 h-4" />
|
||||
<AnthropicIcon className="w-4 h-4" />
|
||||
Claude
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('cursor')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
'px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
formData.provider === 'cursor'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid="provider-select-cursor"
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
Cursor CLI
|
||||
<CursorIcon className="w-4 h-4" />
|
||||
Cursor
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleProviderChange('codex')}
|
||||
className={cn(
|
||||
'px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-center gap-2',
|
||||
formData.provider === 'codex'
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid="provider-select-codex"
|
||||
>
|
||||
<OpenAIIcon className="w-4 h-4" />
|
||||
Codex
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -222,7 +253,7 @@ export function ProfileForm({
|
||||
{formData.provider === 'cursor' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4 text-primary" />
|
||||
<CursorIcon className="w-4 h-4 text-primary" />
|
||||
Cursor Model
|
||||
</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
@@ -283,6 +314,77 @@ export function ProfileForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Codex Model Selection */}
|
||||
{formData.provider === 'codex' && (
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<OpenAIIcon className="w-4 h-4 text-primary" />
|
||||
Codex Model
|
||||
</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{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 (
|
||||
<button
|
||||
key={modelId}
|
||||
type="button"
|
||||
onClick={() => handleCodexModelChange(modelId)}
|
||||
className={cn(
|
||||
'w-full px-3 py-2 rounded-md border text-sm font-medium transition-colors flex items-center justify-between',
|
||||
formData.codexModel === modelId
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent border-border'
|
||||
)}
|
||||
data-testid={`codex-model-select-${modelId}`}
|
||||
>
|
||||
<span>{modelConfig.label}</span>
|
||||
<div className="flex gap-1">
|
||||
{modelConfig.hasReasoning && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
formData.codexModel === modelId
|
||||
? 'border-primary-foreground/50 text-primary-foreground'
|
||||
: 'border-amber-500/50 text-amber-600 dark:text-amber-400'
|
||||
)}
|
||||
>
|
||||
Reasoning
|
||||
</Badge>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'text-xs',
|
||||
formData.codexModel === modelId
|
||||
? 'border-primary-foreground/50 text-primary-foreground'
|
||||
: 'border-muted-foreground/50 text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{modelConfig.badge}
|
||||
</Badge>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Claude Thinking Level */}
|
||||
{formData.provider === 'claude' && supportsThinking && (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -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
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<Terminal className="w-5 h-5 text-brand-500" />
|
||||
<AnthropicIcon className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
Claude Code CLI
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<Icon className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">{title}</h2>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onRefresh}
|
||||
disabled={isChecking}
|
||||
data-testid={refreshTestId}
|
||||
title={`Refresh ${title} detection`}
|
||||
className={cn(
|
||||
'h-9 w-9 rounded-lg',
|
||||
'hover:bg-accent/50 hover:scale-105',
|
||||
'transition-all duration-200'
|
||||
)}
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4', isChecking && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">{description}</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{status.success && status.status === 'installed' ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
|
||||
<div className="w-10 h-10 rounded-xl bg-emerald-500/15 flex items-center justify-center border border-emerald-500/20 shrink-0">
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-500" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-emerald-400">{title} Installed</p>
|
||||
<div className="text-xs text-emerald-400/70 mt-1.5 space-y-0.5">
|
||||
{status.method && (
|
||||
<p>
|
||||
Method: <span className="font-mono">{status.method}</span>
|
||||
</p>
|
||||
)}
|
||||
{status.version && (
|
||||
<p>
|
||||
Version: <span className="font-mono">{status.version}</span>
|
||||
</p>
|
||||
)}
|
||||
{status.path && (
|
||||
<p className="truncate" title={status.path}>
|
||||
Path: <span className="font-mono text-[10px]">{status.path}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{status.recommendation && (
|
||||
<p className="text-xs text-muted-foreground/70 ml-1">{status.recommendation}</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500/15 flex items-center justify-center border border-amber-500/20 shrink-0 mt-0.5">
|
||||
<AlertCircle className="w-5 h-5 text-amber-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-amber-400">{title} Not Detected</p>
|
||||
<p className="text-xs text-amber-400/70 mt-1">
|
||||
{status.recommendation || fallbackRecommendation}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{status.installCommands && (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-medium text-foreground/80">Installation Commands:</p>
|
||||
<div className="space-y-2">
|
||||
{status.installCommands.npm && (
|
||||
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
|
||||
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
|
||||
npm
|
||||
</p>
|
||||
<code className="text-xs text-foreground/80 font-mono break-all">
|
||||
{status.installCommands.npm}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{status.installCommands.macos && (
|
||||
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
|
||||
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
|
||||
macOS/Linux
|
||||
</p>
|
||||
<code className="text-xs text-foreground/80 font-mono break-all">
|
||||
{status.installCommands.macos}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{status.installCommands.windows && (
|
||||
<div className="p-3 rounded-xl bg-accent/30 border border-border/50">
|
||||
<p className="text-[10px] text-muted-foreground mb-1.5 font-medium uppercase tracking-wider">
|
||||
Windows (PowerShell)
|
||||
</p>
|
||||
<code className="text-xs text-foreground/80 font-mono break-all">
|
||||
{status.installCommands.windows}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<CliStatusCard
|
||||
title="Codex CLI"
|
||||
description="Codex CLI powers OpenAI models for coding and automation workflows."
|
||||
status={status}
|
||||
isChecking={isChecking}
|
||||
onRefresh={onRefresh}
|
||||
refreshTestId="refresh-codex-cli"
|
||||
icon={OpenAIIcon}
|
||||
fallbackRecommendation="Install Codex CLI to unlock OpenAI models with tool support."
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<Terminal className="w-5 h-5 text-brand-500" />
|
||||
<CursorIcon className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">Cursor CLI</h2>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<OpenAIIcon className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">{CARD_TITLE}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">{CARD_SUBTITLE}</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-5">
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
id="auto-load-codex-agents"
|
||||
checked={autoLoadCodexAgents}
|
||||
onCheckedChange={(checked) => onAutoLoadCodexAgentsChange(checked === true)}
|
||||
className="mt-1"
|
||||
data-testid="auto-load-codex-agents-checkbox"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="auto-load-codex-agents"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<FileCode className="w-4 h-4 text-brand-500" />
|
||||
{AGENTS_TITLE}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
{AGENTS_DESCRIPTION}{' '}
|
||||
<code className="text-[10px] px-1 py-0.5 rounded bg-accent/50">{AGENTS_PATH}</code>{' '}
|
||||
{AGENTS_SUFFIX}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
id="codex-enable-web-search"
|
||||
checked={codexEnableWebSearch}
|
||||
onCheckedChange={(checked) => onCodexEnableWebSearchChange(checked === true)}
|
||||
className="mt-1"
|
||||
data-testid="codex-enable-web-search-checkbox"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="codex-enable-web-search"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<Globe className="w-4 h-4 text-brand-500" />
|
||||
{WEB_SEARCH_TITLE}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
{WEB_SEARCH_DESCRIPTION}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<Checkbox
|
||||
id="codex-enable-images"
|
||||
checked={codexEnableImages}
|
||||
onCheckedChange={(checked) => onCodexEnableImagesChange(checked === true)}
|
||||
className="mt-1"
|
||||
data-testid="codex-enable-images-checkbox"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label
|
||||
htmlFor="codex-enable-images"
|
||||
className="text-foreground cursor-pointer font-medium flex items-center gap-2"
|
||||
>
|
||||
<ImageIcon className="w-4 h-4 text-brand-500" />
|
||||
{IMAGES_TITLE}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">{IMAGES_DESCRIPTION}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group flex items-start space-x-3 p-3 rounded-xl hover:bg-accent/30 transition-colors duration-200 -mx-3">
|
||||
<div className="w-10 h-10 mt-0.5 rounded-xl flex items-center justify-center shrink-0 bg-brand-500/10">
|
||||
<ShieldCheck className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<Label className="text-foreground font-medium">{SANDBOX_TITLE}</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
{sandboxOption?.description}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={codexSandboxMode}
|
||||
onValueChange={(value) => onCodexSandboxModeChange(value as CodexSandboxMode)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] h-8" data-testid="codex-sandbox-select">
|
||||
<SelectValue aria-label={SANDBOX_SELECT_LABEL} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SANDBOX_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<Label className="text-foreground font-medium">{APPROVAL_TITLE}</Label>
|
||||
<p className="text-xs text-muted-foreground/80 leading-relaxed">
|
||||
{approvalOption?.description}
|
||||
</p>
|
||||
</div>
|
||||
<Select
|
||||
value={codexApprovalPolicy}
|
||||
onValueChange={(value) => onCodexApprovalPolicyChange(value as CodexApprovalPolicy)}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] h-8" data-testid="codex-approval-select">
|
||||
<SelectValue aria-label={APPROVAL_SELECT_LABEL} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{APPROVAL_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<div className="rounded-xl border border-border/60 bg-card/50 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-foreground">{title}</p>
|
||||
<p className="text-xs text-muted-foreground">{subtitle}</p>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{Math.round(safePercentage)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 h-2 w-full rounded-full bg-secondary/60">
|
||||
<div
|
||||
className={cn(
|
||||
'h-full rounded-full transition-all duration-300',
|
||||
getUsageColor(safePercentage)
|
||||
)}
|
||||
style={{ width: `${safePercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
{resetLabel && <p className="mt-2 text-xs text-muted-foreground">{resetLabel}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-2xl overflow-hidden',
|
||||
'border border-border/50',
|
||||
'bg-gradient-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||
'shadow-sm shadow-black/5'
|
||||
)}
|
||||
>
|
||||
<div className="p-6 border-b border-border/50 bg-gradient-to-r from-transparent via-accent/5 to-transparent">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||
<OpenAIIcon className="w-5 h-5 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-foreground tracking-tight">
|
||||
{CODEX_USAGE_TITLE}
|
||||
</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={fetchUsage}
|
||||
disabled={isLoading}
|
||||
className="ml-auto h-9 w-9 rounded-lg hover:bg-accent/50"
|
||||
data-testid="refresh-codex-usage"
|
||||
title={CODEX_REFRESH_LABEL}
|
||||
>
|
||||
<RefreshCw className={cn('w-4 h-4', isLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground/80 ml-12">{CODEX_USAGE_SUBTITLE}</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
{showAuthWarning && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20">
|
||||
<AlertCircle className="w-5 h-5 text-amber-500 mt-0.5" />
|
||||
<div className="text-sm text-amber-400">
|
||||
{CODEX_AUTH_WARNING} Run <span className="font-mono">{CODEX_LOGIN_COMMAND}</span>.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-red-500/10 border border-red-500/20">
|
||||
<AlertCircle className="w-5 h-5 text-red-500 mt-0.5" />
|
||||
<div className="text-sm text-red-400">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
{hasMetrics && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{rateLimitWindows.map((limitWindow, index) => {
|
||||
const { title, subtitle } = getCodexWindowLabel(limitWindow.windowDurationMins);
|
||||
return (
|
||||
<RateLimitCard
|
||||
key={`${title}-${index}`}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
window={limitWindow}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{(planType || credits) && (
|
||||
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
|
||||
{planType && (
|
||||
<div>
|
||||
{PLAN_LABEL}:{' '}
|
||||
<span className="text-foreground">{formatCodexPlanType(planType)}</span>
|
||||
</div>
|
||||
)}
|
||||
{credits && (
|
||||
<div>
|
||||
{CREDITS_LABEL}:{' '}
|
||||
<span className="text-foreground">{formatCodexCredits(credits)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!hasMetrics && !error && canFetchUsage && !isLoading && (
|
||||
<div className="rounded-xl border border-border/60 bg-secondary/20 p-4 text-xs text-muted-foreground">
|
||||
{CODEX_NO_USAGE_MESSAGE}
|
||||
</div>
|
||||
)}
|
||||
{lastUpdatedLabel && (
|
||||
<div className="text-[10px] text-muted-foreground text-right">
|
||||
{UPDATED_LABEL} {lastUpdatedLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<CommandItem
|
||||
key={model.id}
|
||||
value={model.label}
|
||||
onSelect={() => {
|
||||
onChange({ model: model.id });
|
||||
setOpen(false);
|
||||
}}
|
||||
className="group flex items-center justify-between py-2"
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<OpenAIIcon
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col truncate">
|
||||
<span className={cn('truncate font-medium', isSelected && 'text-primary')}>
|
||||
{model.label}
|
||||
</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{model.description}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-6 w-6 hover:bg-transparent hover:text-yellow-500 focus:ring-0',
|
||||
isFavorite
|
||||
? 'text-yellow-500 opacity-100'
|
||||
: 'opacity-0 group-hover:opacity-100 text-muted-foreground'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleFavoriteModel(model.id);
|
||||
}}
|
||||
>
|
||||
<Star className={cn('h-3.5 w-3.5', isFavorite && 'fill-current')} />
|
||||
</Button>
|
||||
{isSelected && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
};
|
||||
|
||||
// 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"
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<Sparkles
|
||||
<CursorIcon
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||
@@ -311,7 +380,7 @@ export function PhaseModelSelector({
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<Brain
|
||||
<AnthropicIcon
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
isSelected ? 'text-primary' : 'text-muted-foreground'
|
||||
@@ -445,7 +514,7 @@ export function PhaseModelSelector({
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden">
|
||||
<Sparkles
|
||||
<CursorIcon
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
groupIsSelected ? 'text-primary' : 'text-muted-foreground'
|
||||
@@ -603,6 +672,10 @@ export function PhaseModelSelector({
|
||||
// Standalone Cursor model
|
||||
return renderCursorModelItem(model);
|
||||
}
|
||||
// Codex model
|
||||
if (model.provider === 'codex') {
|
||||
return renderCodexModelItem(model);
|
||||
}
|
||||
// Claude model
|
||||
return renderClaudeModelItem(model);
|
||||
});
|
||||
@@ -626,6 +699,12 @@ export function PhaseModelSelector({
|
||||
{standaloneCursorModels.map((model) => renderCursorModelItem(model))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{codex.length > 0 && (
|
||||
<CommandGroup heading="Codex Models">
|
||||
{codex.map((model) => renderCodexModelItem(model))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
{/* Usage Info */}
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/20">
|
||||
<Info className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-emerald-400/90">
|
||||
<span className="font-medium">OpenAI via Codex CLI</span>
|
||||
<p className="text-xs text-emerald-400/70 mt-1">
|
||||
Access GPT models with tool support for advanced coding workflows.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CodexCliStatus
|
||||
status={codexCliStatus}
|
||||
isChecking={isCheckingCodexCli}
|
||||
onRefresh={handleRefreshCodexCli}
|
||||
/>
|
||||
<CodexSettings
|
||||
autoLoadCodexAgents={codexAutoLoadAgents}
|
||||
codexSandboxMode={codexSandboxMode}
|
||||
codexApprovalPolicy={codexApprovalPolicy}
|
||||
onAutoLoadCodexAgentsChange={setCodexAutoLoadAgents}
|
||||
onCodexSandboxModeChange={setCodexSandboxMode}
|
||||
onCodexApprovalPolicyChange={setCodexApprovalPolicy}
|
||||
/>
|
||||
{showUsageTracking && <CodexUsageSection />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CodexSettingsTab;
|
||||
@@ -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';
|
||||
|
||||
@@ -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 (
|
||||
<Tabs defaultValue={defaultTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2 mb-6">
|
||||
<TabsList className="grid w-full grid-cols-3 mb-6">
|
||||
<TabsTrigger value="claude" className="flex items-center gap-2">
|
||||
<Bot className="w-4 h-4" />
|
||||
<AnthropicIcon className="w-4 h-4" />
|
||||
Claude
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="cursor" className="flex items-center gap-2">
|
||||
<Terminal className="w-4 h-4" />
|
||||
<CursorIcon className="w-4 h-4" />
|
||||
Cursor
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="codex" className="flex items-center gap-2">
|
||||
<OpenAIIcon className="w-4 h-4" />
|
||||
Codex
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="claude">
|
||||
@@ -29,6 +34,10 @@ export function ProviderTabs({ defaultTab = 'claude' }: ProviderTabsProps) {
|
||||
<TabsContent value="cursor">
|
||||
<CursorSettingsTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="codex">
|
||||
<CodexSettingsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' && (
|
||||
<CodexSetupStep
|
||||
onNext={() => handleNext('codex')}
|
||||
onBack={() => handleBack('codex')}
|
||||
onSkip={handleSkipCodex}
|
||||
/>
|
||||
)}
|
||||
|
||||
{currentStep === 'github' && (
|
||||
<GitHubSetupStep
|
||||
onNext={() => handleNext('github')}
|
||||
|
||||
@@ -2,13 +2,26 @@ import { useState, useCallback } from 'react';
|
||||
import { createLogger } from '@automaker/utils/logger';
|
||||
|
||||
interface UseCliStatusOptions {
|
||||
cliType: 'claude';
|
||||
cliType: 'claude' | 'codex';
|
||||
statusApi: () => Promise<any>;
|
||||
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) {
|
||||
|
||||
809
apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx
Normal file
809
apps/ui/src/components/views/setup-view/steps/cli-setup-step.tsx
Normal file
@@ -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<any>;
|
||||
installApi: () => Promise<any>;
|
||||
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<InstallProgress>) => 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<VerificationStatus>('idle');
|
||||
const [cliVerificationError, setCliVerificationError] = useState<string | null>(null);
|
||||
|
||||
const [apiKeyVerificationStatus, setApiKeyVerificationStatus] =
|
||||
useState<VerificationStatus>('idle');
|
||||
const [apiKeyVerificationError, setApiKeyVerificationError] = useState<string | null>(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 <StatusBadge status="authenticated" label="Verified" />;
|
||||
}
|
||||
if (cliVerificationStatus === 'error') {
|
||||
return <StatusBadge status="error" label="Error" />;
|
||||
}
|
||||
if (isChecking) {
|
||||
return <StatusBadge status="checking" label="Checking..." />;
|
||||
}
|
||||
if (cliStatus?.installed) {
|
||||
return <StatusBadge status="unverified" label="Unverified" />;
|
||||
}
|
||||
return <StatusBadge status="not_installed" label="Not Installed" />;
|
||||
};
|
||||
|
||||
const getApiKeyStatusBadge = () => {
|
||||
if (apiKeyVerificationStatus === 'verified') {
|
||||
return <StatusBadge status="authenticated" label="Verified" />;
|
||||
}
|
||||
if (apiKeyVerificationStatus === 'error') {
|
||||
return <StatusBadge status="error" label="Error" />;
|
||||
}
|
||||
if (hasApiKey) {
|
||||
return <StatusBadge status="unverified" label="Unverified" />;
|
||||
}
|
||||
return <StatusBadge status="not_authenticated" label="Not Set" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="text-center mb-8">
|
||||
<div className="w-16 h-16 rounded-xl bg-brand-500/10 flex items-center justify-center mx-auto mb-4">
|
||||
<ProviderIcon className="w-8 h-8 text-brand-500" />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">{config.displayName} Setup</h2>
|
||||
<p className="text-muted-foreground">Configure authentication for code generation</p>
|
||||
</div>
|
||||
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Info className="w-5 h-5" />
|
||||
Authentication Methods
|
||||
</CardTitle>
|
||||
<Button variant="ghost" size="sm" onClick={checkStatus} disabled={isChecking}>
|
||||
<RefreshCw className={`w-4 h-4 ${isChecking ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription>Choose one of the following methods to authenticate:</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="cli" className="border-border">
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center justify-between w-full pr-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<ProviderIcon
|
||||
className={`w-5 h-5 ${
|
||||
cliVerificationStatus === 'verified'
|
||||
? 'text-green-500'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
/>
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-foreground">{config.cliLabel}</p>
|
||||
<p className="text-sm text-muted-foreground">{config.cliDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
{getCliStatusBadge()}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pt-4 space-y-4">
|
||||
{!cliStatus?.installed && (
|
||||
<div className="space-y-4 p-4 rounded-lg bg-muted/30 border border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="w-4 h-4 text-muted-foreground" />
|
||||
<p className="font-medium text-foreground">Install {config.cliLabel}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">macOS / Linux</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||
{config.installCommands.macos}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyCommand(config.installCommands.macos)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-muted-foreground">Windows</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||
{config.installCommands.windows}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyCommand(config.installCommands.windows)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isInstalling && <TerminalOutput lines={installProgress.output} />}
|
||||
|
||||
<Button
|
||||
onClick={install}
|
||||
disabled={isInstalling}
|
||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid={config.testIds.installButton}
|
||||
>
|
||||
{isInstalling ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Installing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Auto Install
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cliStatus?.installed && cliStatus?.version && (
|
||||
<p className="text-sm text-muted-foreground">Version: {cliStatus.version}</p>
|
||||
)}
|
||||
|
||||
{cliVerificationStatus === 'verifying' && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Verifying CLI authentication...</p>
|
||||
<p className="text-sm text-muted-foreground">Running a test query</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cliVerificationStatus === 'verified' && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">CLI Authentication verified!</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your {config.displayName} CLI is working correctly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cliVerificationStatus === 'error' && cliVerificationError && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<XCircle className="w-5 h-5 text-red-500 shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="font-medium text-foreground">Verification failed</p>
|
||||
{(() => {
|
||||
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 (
|
||||
<>
|
||||
<p className="text-sm text-red-400">{mainError}</p>
|
||||
{details && (
|
||||
<div className="mt-2 p-3 rounded bg-black/20 border border-red-500/20">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Technical details:
|
||||
</p>
|
||||
<pre className="text-xs text-red-300 whitespace-pre-wrap font-mono">
|
||||
{details}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{suggestion && (
|
||||
<div className="mt-3 p-3 rounded bg-muted/50 border border-border">
|
||||
<div className="flex items-start gap-2 mb-2">
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
💡 {suggestion.title}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{suggestion.message}
|
||||
</p>
|
||||
{suggestion.command && (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
{suggestion.action}:
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 bg-muted px-3 py-2 rounded text-sm font-mono text-foreground">
|
||||
{suggestion.command}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => copyCommand(suggestion.command)}
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!suggestion.command && (
|
||||
<p className="text-xs font-medium text-brand-500">
|
||||
→ {suggestion.action}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cliVerificationStatus !== 'verified' && (
|
||||
<Button
|
||||
onClick={verifyCliAuth}
|
||||
disabled={cliVerificationStatus === 'verifying' || !cliStatus?.installed}
|
||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid={config.testIds.verifyCliButton}
|
||||
>
|
||||
{cliVerificationStatus === 'verifying' ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : cliVerificationStatus === 'error' ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Retry Verification
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldCheck className="w-4 h-4 mr-2" />
|
||||
Verify CLI Authentication
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
|
||||
<AccordionItem value="api-key" className="border-border">
|
||||
<AccordionTrigger className="hover:no-underline">
|
||||
<div className="flex items-center justify-between w-full pr-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Key
|
||||
className={`w-5 h-5 ${
|
||||
apiKeyVerificationStatus === 'verified'
|
||||
? 'text-green-500'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
/>
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-foreground">{config.apiKeyLabel}</p>
|
||||
<p className="text-sm text-muted-foreground">{config.apiKeyDescription}</p>
|
||||
</div>
|
||||
</div>
|
||||
{getApiKeyStatusBadge()}
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="pt-4 space-y-4">
|
||||
<div className="space-y-4 p-4 rounded-lg bg-muted/30 border border-border">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={config.testIds.apiKeyInput} className="text-foreground">
|
||||
{config.apiKeyLabel}
|
||||
</Label>
|
||||
<Input
|
||||
id={config.testIds.apiKeyInput}
|
||||
type="password"
|
||||
placeholder={config.apiKeyPlaceholder}
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className="bg-input border-border text-foreground"
|
||||
data-testid={config.testIds.apiKeyInput}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{config.apiKeyHelpText}{' '}
|
||||
<a
|
||||
href={config.apiKeyDocsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-brand-500 hover:underline"
|
||||
>
|
||||
{config.apiKeyDocsLabel}
|
||||
<ExternalLink className="w-3 h-3 inline ml-1" />
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => saveApiKeyToken(apiKey)}
|
||||
disabled={isSavingApiKey || !apiKey.trim()}
|
||||
className="flex-1 bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid={config.testIds.saveApiKeyButton}
|
||||
>
|
||||
{isSavingApiKey ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save API Key'
|
||||
)}
|
||||
</Button>
|
||||
{hasApiKey && (
|
||||
<Button
|
||||
onClick={deleteApiKey}
|
||||
disabled={isDeletingApiKey}
|
||||
variant="outline"
|
||||
className="border-red-500/50 text-red-500 hover:bg-red-500/10 hover:text-red-400"
|
||||
data-testid={config.testIds.deleteApiKeyButton}
|
||||
>
|
||||
{isDeletingApiKey ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{apiKeyVerificationStatus === 'verifying' && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-blue-500/10 border border-blue-500/20">
|
||||
<Loader2 className="w-5 h-5 text-blue-500 animate-spin" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">Verifying API key...</p>
|
||||
<p className="text-sm text-muted-foreground">Running a test query</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiKeyVerificationStatus === 'verified' && (
|
||||
<div className="flex items-center gap-3 p-4 rounded-lg bg-green-500/10 border border-green-500/20">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-500" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground">API Key verified!</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Your API key is working correctly.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiKeyVerificationStatus === 'error' && apiKeyVerificationError && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg bg-red-500/10 border border-red-500/20">
|
||||
<XCircle className="w-5 h-5 text-red-500 shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="font-medium text-foreground">Verification failed</p>
|
||||
{(() => {
|
||||
const parts = apiKeyVerificationError.split('\n\nDetails: ');
|
||||
const mainError = parts[0];
|
||||
const details = parts[1];
|
||||
|
||||
return (
|
||||
<>
|
||||
<p className="text-sm text-red-400">{mainError}</p>
|
||||
{details && (
|
||||
<div className="mt-2 p-3 rounded bg-black/20 border border-red-500/20">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1">
|
||||
Technical details:
|
||||
</p>
|
||||
<pre className="text-xs text-red-300 whitespace-pre-wrap font-mono">
|
||||
{details}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiKeyVerificationStatus !== 'verified' && (
|
||||
<Button
|
||||
onClick={verifyApiKeyAuth}
|
||||
disabled={apiKeyVerificationStatus === 'verifying' || !hasApiKey}
|
||||
className="w-full bg-brand-500 hover:bg-brand-600 text-white"
|
||||
data-testid={config.testIds.verifyApiKeyButton}
|
||||
>
|
||||
{apiKeyVerificationStatus === 'verifying' ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : apiKeyVerificationStatus === 'error' ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Retry Verification
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ShieldCheck className="w-4 h-4 mr-2" />
|
||||
Verify API Key
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-between pt-4">
|
||||
<Button variant="ghost" onClick={onBack} className="text-muted-foreground">
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={onSkip} className="text-muted-foreground">
|
||||
Skip for now
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onNext}
|
||||
disabled={!isReady}
|
||||
className="bg-brand-500 hover:bg-brand-600 text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
data-testid={config.testIds.nextButton}
|
||||
>
|
||||
Continue
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<CliSetupStep
|
||||
config={config}
|
||||
state={{
|
||||
cliStatus: codexCliStatus,
|
||||
authStatus: codexAuthStatus,
|
||||
setCliStatus: setCodexCliStatus,
|
||||
setAuthStatus: setCodexAuthStatus,
|
||||
setInstallProgress: setCodexInstallProgress,
|
||||
getStoreState: () => useSetupStore.getState().codexCliStatus,
|
||||
}}
|
||||
onNext={onNext}
|
||||
onBack={onBack}
|
||||
onSkip={onSkip}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -231,6 +231,13 @@ export async function syncSettingsToServer(): Promise<boolean> {
|
||||
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,
|
||||
|
||||
@@ -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(' ');
|
||||
}
|
||||
|
||||
|
||||
86
apps/ui/src/lib/codex-usage-format.ts
Normal file
86
apps/ui/src/lib/codex-usage-format.ts
Normal file
@@ -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<CodexPlanType, string> = {
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<InstallProgress>) => 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<SetupState & SetupActions>()(
|
||||
// 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 }),
|
||||
}),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string | null> {
|
||||
return findFirstExistingPath(getClaudeCliPaths());
|
||||
}
|
||||
|
||||
export async function findCodexCliPath(): Promise<string | null> {
|
||||
return findFirstExistingPath(getCodexCliPaths());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Claude authentication status by checking various indicators
|
||||
*/
|
||||
@@ -818,3 +872,56 @@ export async function getClaudeAuthIndicators(): Promise<ClaudeAuthIndicators> {
|
||||
|
||||
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<string, unknown>, keys: readonly string[]): boolean {
|
||||
return keys.some((key) => typeof record[key] === 'string' && record[key]);
|
||||
}
|
||||
|
||||
function getNestedTokens(record: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const tokens = record[CODEX_TOKENS_KEY];
|
||||
if (tokens && typeof tokens === 'object' && !Array.isArray(tokens)) {
|
||||
return tokens as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function getCodexAuthIndicators(): Promise<CodexAuthIndicators> {
|
||||
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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
|
||||
44
libs/types/src/codex.ts
Normal file
44
libs/types/src/codex.ts
Normal file
@@ -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[];
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ThinkingLevel, string> = {
|
||||
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<ReasoningEffort, string> = {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -7,12 +7,70 @@ export const CLAUDE_MODEL_MAP: Record<string, string> = {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, McpServerConfig>;
|
||||
/** 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<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -166,4 +209,5 @@ export interface ModelDefinition {
|
||||
supportsTools?: boolean;
|
||||
tier?: 'basic' | 'standard' | 'premium';
|
||||
default?: boolean;
|
||||
hasReasoning?: boolean;
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
|
||||
12
package-lock.json
generated
12
package-lock.json
generated
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user