feature/codex-cli

This commit is contained in:
DhanushSantosh
2026-01-06 04:52:25 +05:30
parent 4d4025ca06
commit a57dcc170d
54 changed files with 5562 additions and 91 deletions

View File

@@ -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));

View 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');
}
}
}

View 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);
}

View 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;
}
}

View 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 };
}
}

View 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;
}

View File

@@ -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 {

View File

@@ -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
});

View File

@@ -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());

View 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),
});
}
};
}

View 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),
});
}
};
}

View 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),
});
}
};
}

View 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];
}
}
};
}