From c1fccaaf37dbef99fb51ba34592c9a3bd2a2d84c Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:24:50 +0900 Subject: [PATCH] support for codex --- package-lock.json | 10 ++ package.json | 1 + resources/global/en/config.yaml | 3 + resources/global/ja/config.yaml | 3 + src/__tests__/models.test.ts | 13 ++- src/agents/runner.ts | 44 ++++++++ src/codex/client.ts | 184 ++++++++++++++++++++++++++++++++ src/codex/index.ts | 5 + src/config/globalConfig.ts | 9 ++ src/config/initialization.ts | 20 +++- src/config/projectConfig.ts | 2 + src/config/workflowLoader.ts | 1 + src/index.ts | 3 + src/models/schemas.ts | 6 +- src/models/types.ts | 5 + src/workflow/engine.ts | 1 + 16 files changed, 306 insertions(+), 4 deletions(-) create mode 100644 src/codex/client.ts create mode 100644 src/codex/index.ts diff --git a/package-lock.json b/package-lock.json index 2934b85..4ac5b80 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.19", + "@openai/codex-sdk": "^0.91.0", "chalk": "^5.3.0", "commander": "^12.1.0", "yaml": "^2.4.5", @@ -979,6 +980,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@openai/codex-sdk": { + "version": "0.91.0", + "resolved": "https://registry.npmjs.org/@openai/codex-sdk/-/codex-sdk-0.91.0.tgz", + "integrity": "sha512-YYf8QNkpQyuzNgn9Mf9D3G1pp0ObI98ADCNqASBpdlpxqykMyABgQdMRdc4c/l1KdoTnGVkUw0ljXaCHurs5vA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.56.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz", diff --git a/package.json b/package.json index a581524..82c8ac5 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ ], "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.19", + "@openai/codex-sdk": "^0.91.0", "chalk": "^5.3.0", "commander": "^12.1.0", "yaml": "^2.4.5", diff --git a/resources/global/en/config.yaml b/resources/global/en/config.yaml index 67565cd..891f41f 100644 --- a/resources/global/en/config.yaml +++ b/resources/global/en/config.yaml @@ -13,6 +13,9 @@ default_workflow: default # Log level: debug, info, warn, error log_level: info +# Provider runtime: claude or codex +provider: claude + # Debug settings (optional) # debug: # enabled: false diff --git a/resources/global/ja/config.yaml b/resources/global/ja/config.yaml index d9d60ae..fa34808 100644 --- a/resources/global/ja/config.yaml +++ b/resources/global/ja/config.yaml @@ -13,6 +13,9 @@ default_workflow: default # ログレベル: debug, info, warn, error log_level: info +# プロバイダー: claude または codex +provider: claude + # デバッグ設定 (オプション) # debug: # enabled: false diff --git a/src/__tests__/models.test.ts b/src/__tests__/models.test.ts index 6392efb..add1b31 100644 --- a/src/__tests__/models.test.ts +++ b/src/__tests__/models.test.ts @@ -119,6 +119,17 @@ describe('CustomAgentConfigSchema', () => { expect(result.claude_agent).toBe('architect'); }); + it('should accept agent with provider override', () => { + const config = { + name: 'my-agent', + prompt: 'You are a helpful assistant.', + provider: 'codex', + }; + + const result = CustomAgentConfigSchema.parse(config); + expect(result.provider).toBe('codex'); + }); + it('should reject agent without any prompt source', () => { const config = { name: 'my-agent', @@ -136,6 +147,7 @@ describe('GlobalConfigSchema', () => { expect(result.trusted_directories).toEqual([]); expect(result.default_workflow).toBe('default'); expect(result.log_level).toBe('info'); + expect(result.provider).toBe('claude'); }); it('should accept valid config', () => { @@ -175,4 +187,3 @@ describe('GENERIC_STATUS_PATTERNS', () => { expect(new RegExp(GENERIC_STATUS_PATTERNS.improve).test('[MAGI:IMPROVE]')).toBe(true); }); }); - diff --git a/src/agents/runner.ts b/src/agents/runner.ts index a726347..512f7ae 100644 --- a/src/agents/runner.ts +++ b/src/agents/runner.ts @@ -13,7 +13,10 @@ import { ClaudeCallOptions, } from '../claude/client.js'; import { type StreamCallback, type PermissionHandler, type AskUserQuestionHandler } from '../claude/process.js'; +import { callCodex, callCodexCustom, type CodexCallOptions } from '../codex/client.js'; import { loadCustomAgents, loadAgentPrompt } from '../config/loader.js'; +import { loadGlobalConfig } from '../config/globalConfig.js'; +import { loadProjectConfig } from '../config/projectConfig.js'; import type { AgentResponse, CustomAgentConfig } from '../models/types.js'; export type { StreamCallback }; @@ -23,6 +26,7 @@ export interface RunAgentOptions { cwd: string; sessionId?: string; model?: string; + provider?: 'claude' | 'codex'; /** Resolved path to agent prompt file */ agentPath?: string; onStream?: StreamCallback; @@ -40,6 +44,22 @@ const DEFAULT_AGENT_TOOLS: Record = { planner: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'], }; +type AgentProvider = 'claude' | 'codex'; + +function resolveProvider(cwd: string, options?: RunAgentOptions, agentConfig?: CustomAgentConfig): AgentProvider { + if (options?.provider) return options.provider; + if (agentConfig?.provider) return agentConfig.provider; + const projectConfig = loadProjectConfig(cwd); + if (projectConfig.provider) return projectConfig.provider; + try { + const globalConfig = loadGlobalConfig(); + if (globalConfig.provider) return globalConfig.provider; + } catch { + // Ignore missing global config; fallback below + } + return 'claude'; +} + /** Get git diff for review context */ export function getGitDiff(cwd: string): string { try { @@ -102,6 +122,18 @@ export async function runCustomAgent( // Custom agent with prompt const systemPrompt = loadAgentPrompt(agentConfig); const tools = agentConfig.allowedTools || ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch']; + const provider = resolveProvider(options.cwd, options, agentConfig); + if (provider === 'codex') { + const callOptions: CodexCallOptions = { + cwd: options.cwd, + sessionId: options.sessionId, + model: options.model || agentConfig.model, + statusPatterns: agentConfig.statusPatterns, + onStream: options.onStream, + }; + return callCodexCustom(agentConfig.name, task, systemPrompt, callOptions); + } + const callOptions: ClaudeCallOptions = { cwd: options.cwd, sessionId: options.sessionId, @@ -167,6 +199,18 @@ export async function runAgent( } const systemPrompt = loadAgentPromptFromPath(options.agentPath); const tools = DEFAULT_AGENT_TOOLS[agentName] || ['Read', 'Glob', 'Grep', 'WebSearch', 'WebFetch']; + const provider = resolveProvider(options.cwd, options); + + if (provider === 'codex') { + const callOptions: CodexCallOptions = { + cwd: options.cwd, + sessionId: options.sessionId, + model: options.model, + systemPrompt, + onStream: options.onStream, + }; + return callCodex(agentName, task, callOptions); + } const callOptions: ClaudeCallOptions = { cwd: options.cwd, diff --git a/src/codex/client.ts b/src/codex/client.ts new file mode 100644 index 0000000..c9af246 --- /dev/null +++ b/src/codex/client.ts @@ -0,0 +1,184 @@ +/** + * Codex SDK integration for agent interactions + * + * Uses @openai/codex-sdk for native TypeScript integration. + */ + +import { Codex } from '@openai/codex-sdk'; +import type { AgentResponse, Status } from '../models/types.js'; +import { GENERIC_STATUS_PATTERNS } from '../models/schemas.js'; +import { detectStatus } from '../claude/client.js'; +import type { StreamCallback } from '../claude/process.js'; +import { createLogger } from '../utils/debug.js'; + +const log = createLogger('codex-sdk'); + +/** Options for calling Codex */ +export interface CodexCallOptions { + cwd: string; + sessionId?: string; + model?: string; + systemPrompt?: string; + statusPatterns?: Record; + /** Enable streaming mode with callback (best-effort) */ + onStream?: StreamCallback; +} + +function extractThreadId(value: unknown): string | undefined { + if (!value || typeof value !== 'object') return undefined; + const record = value as Record; + const id = record.id ?? record.thread_id ?? record.threadId; + return typeof id === 'string' ? id : undefined; +} + +function normalizeCodexResult(result: unknown): string { + if (result == null) return ''; + if (typeof result === 'string') return result; + if (typeof result !== 'object') return String(result); + + const record = result as Record; + const directFields = ['output_text', 'output', 'content', 'text', 'message']; + for (const field of directFields) { + const value = record[field]; + if (typeof value === 'string') { + return value; + } + } + + if (Array.isArray(record.output)) { + const first = record.output[0]; + if (typeof first === 'string') return first; + if (first && typeof first === 'object') { + const text = (first as Record).text; + if (typeof text === 'string') return text; + } + } + + if (Array.isArray(record.choices)) { + const firstChoice = record.choices[0] as Record | undefined; + const message = firstChoice?.message as Record | undefined; + const content = message?.content; + if (typeof content === 'string') return content; + } + + try { + return JSON.stringify(result, null, 2); + } catch { + return String(result); + } +} + +function emitInit( + onStream: StreamCallback | undefined, + model: string | undefined, + sessionId: string | undefined +): void { + if (!onStream) return; + onStream({ + type: 'init', + data: { + model: model || 'codex', + sessionId: sessionId || 'unknown', + }, + }); +} + +function emitText(onStream: StreamCallback | undefined, text: string): void { + if (!onStream || !text) return; + onStream({ type: 'text', data: { text } }); +} + +function emitResult( + onStream: StreamCallback | undefined, + success: boolean, + result: string, + sessionId: string | undefined +): void { + if (!onStream) return; + onStream({ + type: 'result', + data: { + result, + sessionId: sessionId || 'unknown', + success, + }, + }); +} + +function determineStatus(content: string, patterns: Record): Status { + return detectStatus(content, patterns); +} + +/** + * Call Codex with an agent prompt. + */ +export async function callCodex( + agentType: string, + prompt: string, + options: CodexCallOptions +): Promise { + const codex = new Codex(); + const thread = options.sessionId + ? await codex.resumeThread(options.sessionId) + : await codex.startThread(); + const threadId = extractThreadId(thread) || options.sessionId; + + const fullPrompt = options.systemPrompt + ? `${options.systemPrompt}\n\n${prompt}` + : prompt; + + emitInit(options.onStream, options.model, threadId); + + try { + log.debug('Executing Codex thread', { + agentType, + model: options.model, + hasSystemPrompt: !!options.systemPrompt, + }); + + const runOptions = options.model ? { model: options.model } : undefined; + const result = await (thread as { run: (p: string, o?: unknown) => Promise }) + .run(fullPrompt, runOptions); + + const content = normalizeCodexResult(result).trim(); + emitText(options.onStream, content); + emitResult(options.onStream, true, content, threadId); + + const patterns = options.statusPatterns || GENERIC_STATUS_PATTERNS; + const status = determineStatus(content, patterns); + + return { + agent: agentType, + status, + content, + timestamp: new Date(), + sessionId: threadId, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + emitResult(options.onStream, false, message, threadId); + + return { + agent: agentType, + status: 'blocked', + content: message, + timestamp: new Date(), + sessionId: threadId, + }; + } +} + +/** + * Call Codex with a custom agent configuration (system prompt + prompt). + */ +export async function callCodexCustom( + agentName: string, + prompt: string, + systemPrompt: string, + options: CodexCallOptions +): Promise { + return callCodex(agentName, prompt, { + ...options, + systemPrompt, + }); +} diff --git a/src/codex/index.ts b/src/codex/index.ts new file mode 100644 index 0000000..d9229b7 --- /dev/null +++ b/src/codex/index.ts @@ -0,0 +1,5 @@ +/** + * Codex integration exports + */ + +export * from './client.js'; diff --git a/src/config/globalConfig.ts b/src/config/globalConfig.ts index 7fdf7d6..2daccce 100644 --- a/src/config/globalConfig.ts +++ b/src/config/globalConfig.ts @@ -29,6 +29,7 @@ export function loadGlobalConfig(): GlobalConfig { trustedDirectories: parsed.trusted_directories, defaultWorkflow: parsed.default_workflow, logLevel: parsed.log_level, + provider: parsed.provider, debug: parsed.debug ? { enabled: parsed.debug.enabled, logFile: parsed.debug.log_file, @@ -44,6 +45,7 @@ export function saveGlobalConfig(config: GlobalConfig): void { trusted_directories: config.trustedDirectories, default_workflow: config.defaultWorkflow, log_level: config.logLevel, + provider: config.provider, }; if (config.debug) { raw.debug = { @@ -71,6 +73,13 @@ export function setLanguage(language: Language): void { saveGlobalConfig(config); } +/** Set provider setting */ +export function setProvider(provider: 'claude' | 'codex'): void { + const config = loadGlobalConfig(); + config.provider = provider; + saveGlobalConfig(config); +} + /** Add a trusted directory */ export function addTrustedDirectory(dir: string): void { const config = loadGlobalConfig(); diff --git a/src/config/initialization.ts b/src/config/initialization.ts index 1f6ca4a..146dfdf 100644 --- a/src/config/initialization.ts +++ b/src/config/initialization.ts @@ -22,7 +22,7 @@ import { copyLanguageResourcesToDir, copyProjectResourcesToDir, } from '../resources/index.js'; -import { setLanguage } from './globalConfig.js'; +import { setLanguage, setProvider } from './globalConfig.js'; /** * Check if language-specific resources need to be initialized. @@ -51,6 +51,22 @@ export async function promptLanguageSelection(): Promise { ); } +/** + * Prompt user to select provider for resources. + */ +export async function promptProviderSelection(): Promise<'claude' | 'codex'> { + const options: { label: string; value: 'claude' | 'codex' }[] = [ + { label: 'Claude Code', value: 'claude' }, + { label: 'Codex', value: 'codex' }, + ]; + + return await selectOptionWithDefault( + 'Select provider (Claude Code or Codex) / プロバイダーを選択してください:', + options, + 'claude' + ); +} + /** * Initialize global takt directory structure with language selection. * If agents/workflows don't exist, prompts user for language preference. @@ -65,12 +81,14 @@ export async function initGlobalDirs(): Promise { if (needsSetup) { // Ask user for language preference const lang = await promptLanguageSelection(); + const provider = await promptProviderSelection(); // Copy language-specific resources (agents, workflows, config.yaml) copyLanguageResourcesToDir(getGlobalConfigDir(), lang); // Explicitly save the selected language (handles case where config.yaml existed) setLanguage(lang); + setProvider(provider); } else { // Just copy base global resources (won't overwrite existing) copyGlobalResourcesToDir(getGlobalConfigDir()); diff --git a/src/config/projectConfig.ts b/src/config/projectConfig.ts index 6b55a5d..8eeec69 100644 --- a/src/config/projectConfig.ts +++ b/src/config/projectConfig.ts @@ -24,6 +24,8 @@ export type ProjectPermissionMode = PermissionMode; export interface ProjectLocalConfig { /** Current workflow name */ workflow?: string; + /** Provider selection for agent runtime */ + provider?: 'claude' | 'codex'; /** Permission mode setting */ permissionMode?: PermissionMode; /** @deprecated Use permissionMode instead. Auto-approve all permissions in this project */ diff --git a/src/config/workflowLoader.ts b/src/config/workflowLoader.ts index fb726a0..db946bc 100644 --- a/src/config/workflowLoader.ts +++ b/src/config/workflowLoader.ts @@ -66,6 +66,7 @@ function normalizeWorkflowConfig(raw: unknown, workflowDir: string): WorkflowCon agent: step.agent, agentDisplayName: step.agent_name || extractAgentDisplayName(step.agent), agentPath: resolveAgentPathForWorkflow(step.agent, workflowDir), + provider: step.provider, instructionTemplate: step.instruction_template || step.instruction || '{task}', transitions: step.transitions.map((t) => ({ condition: t.condition, diff --git a/src/index.ts b/src/index.ts index fbee7b1..d06f67e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,9 @@ export * from './config/index.js'; // Claude integration export * from './claude/index.js'; +// Codex integration +export * from './codex/index.js'; + // Agent execution export * from './agents/index.js'; diff --git a/src/models/schemas.ts b/src/models/schemas.ts index c024f8d..242a577 100644 --- a/src/models/schemas.ts +++ b/src/models/schemas.ts @@ -59,6 +59,7 @@ export const WorkflowStepRawSchema = z.object({ agent: z.string().min(1), /** Display name for the agent (shown in output). Falls back to agent basename if not specified */ agent_name: z.string().optional(), + provider: z.enum(['claude', 'codex']).optional(), instruction: z.string().optional(), instruction_template: z.string().optional(), pass_previous_response: z.boolean().optional().default(true), @@ -90,6 +91,7 @@ export const CustomAgentConfigSchema = z.object({ status_patterns: z.record(z.string(), z.string()).optional(), claude_agent: z.string().optional(), claude_skill: z.string().optional(), + provider: z.enum(['claude', 'codex']).optional(), model: z.string().optional(), }).refine( (data) => data.prompt_file || data.prompt || data.claude_agent || data.claude_skill, @@ -111,6 +113,7 @@ export const GlobalConfigSchema = z.object({ trusted_directories: z.array(z.string()).optional().default([]), default_workflow: z.string().optional().default('default'), log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'), + provider: z.enum(['claude', 'codex']).optional().default('claude'), debug: DebugConfigSchema.optional(), }); @@ -118,6 +121,7 @@ export const GlobalConfigSchema = z.object({ export const ProjectConfigSchema = z.object({ workflow: z.string().optional(), agents: z.array(CustomAgentConfigSchema).optional(), + provider: z.enum(['claude', 'codex']).optional(), }); /** @@ -134,5 +138,3 @@ export const GENERIC_STATUS_PATTERNS: Record = { done: '\\[\\w+:(DONE|FIXED)\\]', blocked: '\\[\\w+:BLOCKED\\]', }; - - diff --git a/src/models/types.ts b/src/models/types.ts index 71804cf..53bd2f6 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -62,6 +62,8 @@ export interface WorkflowStep { agentDisplayName: string; /** Resolved absolute path to agent prompt file (set by loader) */ agentPath?: string; + /** Provider override for this step */ + provider?: 'claude' | 'codex'; instructionTemplate: string; transitions: WorkflowTransition[]; passPreviousResponse: boolean; @@ -119,6 +121,7 @@ export interface CustomAgentConfig { statusPatterns?: Record; claudeAgent?: string; claudeSkill?: string; + provider?: 'claude' | 'codex'; model?: string; } @@ -137,6 +140,7 @@ export interface GlobalConfig { trustedDirectories: string[]; defaultWorkflow: string; logLevel: 'debug' | 'info' | 'warn' | 'error'; + provider?: 'claude' | 'codex'; debug?: DebugConfig; } @@ -144,4 +148,5 @@ export interface GlobalConfig { export interface ProjectConfig { workflow?: string; agents?: CustomAgentConfig[]; + provider?: 'claude' | 'codex'; } diff --git a/src/workflow/engine.ts b/src/workflow/engine.ts index 5dcbf43..7e8e0ac 100644 --- a/src/workflow/engine.ts +++ b/src/workflow/engine.ts @@ -147,6 +147,7 @@ export class WorkflowEngine extends EventEmitter { cwd: this.cwd, sessionId, agentPath: step.agentPath, + provider: step.provider, onStream: this.options.onStream, onPermissionRequest: this.options.onPermissionRequest, onAskUserQuestion: this.options.onAskUserQuestion,