support for codex

This commit is contained in:
nrslib 2026-01-26 16:24:50 +09:00
parent 65a9553bb9
commit c1fccaaf37
16 changed files with 306 additions and 4 deletions

10
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

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

View File

@ -13,6 +13,9 @@ default_workflow: default
# ログレベル: debug, info, warn, error
log_level: info
# プロバイダー: claude または codex
provider: claude
# デバッグ設定 (オプション)
# debug:
# enabled: false

View File

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

View File

@ -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<string, string[]> = {
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,

184
src/codex/client.ts Normal file
View File

@ -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<string, string>;
/** 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<string, unknown>;
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<string, unknown>;
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<string, unknown>).text;
if (typeof text === 'string') return text;
}
}
if (Array.isArray(record.choices)) {
const firstChoice = record.choices[0] as Record<string, unknown> | undefined;
const message = firstChoice?.message as Record<string, unknown> | 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<string, string>): Status {
return detectStatus(content, patterns);
}
/**
* Call Codex with an agent prompt.
*/
export async function callCodex(
agentType: string,
prompt: string,
options: CodexCallOptions
): Promise<AgentResponse> {
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<unknown> })
.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<AgentResponse> {
return callCodex(agentName, prompt, {
...options,
systemPrompt,
});
}

5
src/codex/index.ts Normal file
View File

@ -0,0 +1,5 @@
/**
* Codex integration exports
*/
export * from './client.js';

View File

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

View File

@ -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<Language> {
);
}
/**
* 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<void> {
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());

View File

@ -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 */

View File

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

View File

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

View File

@ -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<string, string> = {
done: '\\[\\w+:(DONE|FIXED)\\]',
blocked: '\\[\\w+:BLOCKED\\]',
};

View File

@ -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<string, string>;
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';
}

View File

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