mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-01-28 12:27:35 +00:00
feat: Introducing Codex to the Claude code UI project. Improve the Settings and Onboarding UX to accomodate more agents.
This commit is contained in:
@@ -8,6 +8,7 @@ import { userDb, apiKeysDb, githubTokensDb } from '../database/db.js';
|
||||
import { addProjectManually } from '../projects.js';
|
||||
import { queryClaudeSDK } from '../claude-sdk.js';
|
||||
import { spawnCursor } from '../cursor-cli.js';
|
||||
import { queryCodex } from '../openai-codex.js';
|
||||
import { Octokit } from '@octokit/rest';
|
||||
|
||||
const router = express.Router();
|
||||
@@ -846,8 +847,8 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
return res.status(400).json({ error: 'message is required' });
|
||||
}
|
||||
|
||||
if (!['claude', 'cursor'].includes(provider)) {
|
||||
return res.status(400).json({ error: 'provider must be "claude" or "cursor"' });
|
||||
if (!['claude', 'cursor', 'codex'].includes(provider)) {
|
||||
return res.status(400).json({ error: 'provider must be "claude", "cursor", or "codex"' });
|
||||
}
|
||||
|
||||
// Validate GitHub branch/PR creation requirements
|
||||
@@ -951,6 +952,16 @@ router.post('/', validateExternalApiKey, async (req, res) => {
|
||||
model: model || undefined,
|
||||
skipPermissions: true // Bypass permissions for Cursor
|
||||
}, writer);
|
||||
} else if (provider === 'codex') {
|
||||
console.log('🤖 Starting Codex SDK session');
|
||||
|
||||
await queryCodex(message.trim(), {
|
||||
projectPath: finalProjectPath,
|
||||
cwd: finalProjectPath,
|
||||
sessionId: null,
|
||||
model: model || 'gpt-5.2',
|
||||
permissionMode: 'bypassPermissions'
|
||||
}, writer);
|
||||
}
|
||||
|
||||
// Handle GitHub branch and PR creation after successful agent completion
|
||||
|
||||
@@ -54,6 +54,26 @@ router.get('/cursor/status', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/codex/status', async (req, res) => {
|
||||
try {
|
||||
const result = await checkCodexCredentials();
|
||||
|
||||
res.json({
|
||||
authenticated: result.authenticated,
|
||||
email: result.email,
|
||||
error: result.error
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking Codex auth status:', error);
|
||||
res.status(500).json({
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
async function checkClaudeCredentials() {
|
||||
try {
|
||||
const credPath = path.join(os.homedir(), '.claude', '.credentials.json');
|
||||
@@ -177,4 +197,67 @@ function checkCursorStatus() {
|
||||
});
|
||||
}
|
||||
|
||||
async function checkCodexCredentials() {
|
||||
try {
|
||||
const authPath = path.join(os.homedir(), '.codex', 'auth.json');
|
||||
const content = await fs.readFile(authPath, 'utf8');
|
||||
const auth = JSON.parse(content);
|
||||
|
||||
// Tokens are nested under 'tokens' key
|
||||
const tokens = auth.tokens || {};
|
||||
|
||||
// Check for valid tokens (id_token or access_token)
|
||||
if (tokens.id_token || tokens.access_token) {
|
||||
// Try to extract email from id_token JWT payload
|
||||
let email = 'Authenticated';
|
||||
if (tokens.id_token) {
|
||||
try {
|
||||
// JWT is base64url encoded: header.payload.signature
|
||||
const parts = tokens.id_token.split('.');
|
||||
if (parts.length >= 2) {
|
||||
// Decode the payload (second part)
|
||||
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
||||
email = payload.email || payload.user || 'Authenticated';
|
||||
}
|
||||
} catch {
|
||||
// If JWT decoding fails, use fallback
|
||||
email = 'Authenticated';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
email
|
||||
};
|
||||
}
|
||||
|
||||
// Also check for OPENAI_API_KEY as fallback auth method
|
||||
if (auth.OPENAI_API_KEY) {
|
||||
return {
|
||||
authenticated: true,
|
||||
email: 'API Key Auth'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'No valid tokens found'
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: 'Codex not configured'
|
||||
};
|
||||
}
|
||||
return {
|
||||
authenticated: false,
|
||||
email: null,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
310
server/routes/codex.js
Normal file
310
server/routes/codex.js
Normal file
@@ -0,0 +1,310 @@
|
||||
import express from 'express';
|
||||
import { spawn } from 'child_process';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import TOML from '@iarna/toml';
|
||||
import { getCodexSessions, getCodexSessionMessages, deleteCodexSession } from '../projects.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/config', async (req, res) => {
|
||||
try {
|
||||
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
|
||||
const content = await fs.readFile(configPath, 'utf8');
|
||||
const config = TOML.parse(content);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
config: {
|
||||
model: config.model || null,
|
||||
mcpServers: config.mcp_servers || {},
|
||||
approvalMode: config.approval_mode || 'suggest'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
res.json({
|
||||
success: true,
|
||||
config: {
|
||||
model: null,
|
||||
mcpServers: {},
|
||||
approvalMode: 'suggest'
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.error('Error reading Codex config:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sessions', async (req, res) => {
|
||||
try {
|
||||
const { projectPath } = req.query;
|
||||
|
||||
if (!projectPath) {
|
||||
return res.status(400).json({ success: false, error: 'projectPath query parameter required' });
|
||||
}
|
||||
|
||||
const sessions = await getCodexSessions(projectPath);
|
||||
res.json({ success: true, sessions });
|
||||
} catch (error) {
|
||||
console.error('Error fetching Codex sessions:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/sessions/:sessionId/messages', async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
const { limit, offset } = req.query;
|
||||
|
||||
const result = await getCodexSessionMessages(
|
||||
sessionId,
|
||||
limit ? parseInt(limit, 10) : null,
|
||||
offset ? parseInt(offset, 10) : 0
|
||||
);
|
||||
|
||||
res.json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
console.error('Error fetching Codex session messages:', error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/sessions/:sessionId', async (req, res) => {
|
||||
try {
|
||||
const { sessionId } = req.params;
|
||||
await deleteCodexSession(sessionId);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error(`Error deleting Codex session ${req.params.sessionId}:`, error);
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// MCP Server Management Routes
|
||||
|
||||
router.get('/mcp/cli/list', async (req, res) => {
|
||||
try {
|
||||
const proc = spawn('codex', ['mcp', 'list'], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout.on('data', (data) => { stdout += data.toString(); });
|
||||
proc.stderr.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
res.json({ success: true, output: stdout, servers: parseCodexListOutput(stdout) });
|
||||
} else {
|
||||
res.status(500).json({ error: 'Codex CLI command failed', details: stderr });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message });
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to list MCP servers', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/mcp/cli/add', async (req, res) => {
|
||||
try {
|
||||
const { name, command, args = [], env = {} } = req.body;
|
||||
|
||||
if (!name || !command) {
|
||||
return res.status(400).json({ error: 'name and command are required' });
|
||||
}
|
||||
|
||||
// Build: codex mcp add <name> [-e KEY=VAL]... -- <command> [args...]
|
||||
let cliArgs = ['mcp', 'add', name];
|
||||
|
||||
Object.entries(env).forEach(([key, value]) => {
|
||||
cliArgs.push('-e', `${key}=${value}`);
|
||||
});
|
||||
|
||||
cliArgs.push('--', command);
|
||||
|
||||
if (args && args.length > 0) {
|
||||
cliArgs.push(...args);
|
||||
}
|
||||
|
||||
const proc = spawn('codex', cliArgs, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout.on('data', (data) => { stdout += data.toString(); });
|
||||
proc.stderr.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
res.json({ success: true, output: stdout, message: `MCP server "${name}" added successfully` });
|
||||
} else {
|
||||
res.status(400).json({ error: 'Codex CLI command failed', details: stderr });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message });
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to add MCP server', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/mcp/cli/remove/:name', async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
const proc = spawn('codex', ['mcp', 'remove', name], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout.on('data', (data) => { stdout += data.toString(); });
|
||||
proc.stderr.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
res.json({ success: true, output: stdout, message: `MCP server "${name}" removed successfully` });
|
||||
} else {
|
||||
res.status(400).json({ error: 'Codex CLI command failed', details: stderr });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message });
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to remove MCP server', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/mcp/cli/get/:name', async (req, res) => {
|
||||
try {
|
||||
const { name } = req.params;
|
||||
|
||||
const proc = spawn('codex', ['mcp', 'get', name], { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
proc.stdout.on('data', (data) => { stdout += data.toString(); });
|
||||
proc.stderr.on('data', (data) => { stderr += data.toString(); });
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
res.json({ success: true, output: stdout, server: parseCodexGetOutput(stdout) });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Codex CLI command failed', details: stderr });
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (error) => {
|
||||
res.status(500).json({ error: 'Failed to run Codex CLI', details: error.message });
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to get MCP server details', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/mcp/config/read', async (req, res) => {
|
||||
try {
|
||||
const configPath = path.join(os.homedir(), '.codex', 'config.toml');
|
||||
|
||||
let configData = null;
|
||||
|
||||
try {
|
||||
const fileContent = await fs.readFile(configPath, 'utf8');
|
||||
configData = TOML.parse(fileContent);
|
||||
} catch (error) {
|
||||
// Config file doesn't exist
|
||||
}
|
||||
|
||||
if (!configData) {
|
||||
return res.json({ success: false, message: 'No Codex configuration file found', servers: [] });
|
||||
}
|
||||
|
||||
const servers = [];
|
||||
|
||||
if (configData.mcp_servers && typeof configData.mcp_servers === 'object') {
|
||||
for (const [name, config] of Object.entries(configData.mcp_servers)) {
|
||||
servers.push({
|
||||
id: name,
|
||||
name: name,
|
||||
type: 'stdio',
|
||||
scope: 'user',
|
||||
config: {
|
||||
command: config.command || '',
|
||||
args: config.args || [],
|
||||
env: config.env || {}
|
||||
},
|
||||
raw: config
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ success: true, configPath, servers });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Failed to read Codex configuration', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
function parseCodexListOutput(output) {
|
||||
const servers = [];
|
||||
const lines = output.split('\n').filter(line => line.trim());
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes(':')) {
|
||||
const colonIndex = line.indexOf(':');
|
||||
const name = line.substring(0, colonIndex).trim();
|
||||
|
||||
if (!name) continue;
|
||||
|
||||
const rest = line.substring(colonIndex + 1).trim();
|
||||
let description = rest;
|
||||
let status = 'unknown';
|
||||
|
||||
if (rest.includes('✓') || rest.includes('✗')) {
|
||||
const statusMatch = rest.match(/(.*?)\s*-\s*([✓✗].*)$/);
|
||||
if (statusMatch) {
|
||||
description = statusMatch[1].trim();
|
||||
status = statusMatch[2].includes('✓') ? 'connected' : 'failed';
|
||||
}
|
||||
}
|
||||
|
||||
servers.push({ name, type: 'stdio', status, description });
|
||||
}
|
||||
}
|
||||
|
||||
return servers;
|
||||
}
|
||||
|
||||
function parseCodexGetOutput(output) {
|
||||
try {
|
||||
const jsonMatch = output.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[0]);
|
||||
}
|
||||
|
||||
const server = { raw_output: output };
|
||||
const lines = output.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('Name:')) server.name = line.split(':')[1]?.trim();
|
||||
else if (line.includes('Type:')) server.type = line.split(':')[1]?.trim();
|
||||
else if (line.includes('Command:')) server.command = line.split(':')[1]?.trim();
|
||||
}
|
||||
|
||||
return server;
|
||||
} catch (error) {
|
||||
return { raw_output: output, parse_error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user