From fdd07a3a89dad35cbe697c5a9ac864c92d8ce2e9 Mon Sep 17 00:00:00 2001 From: Nanook Date: Tue, 24 Mar 2026 07:39:41 +0000 Subject: [PATCH] =?UTF-8?q?feat(adapters):=20Codex=20CLI=20adapter=20?= =?UTF-8?q?=E2=80=94=20export=20+=20import=20(#32)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add OpenAI Codex CLI adapter, closing #32 (help wanted). ## What - New — and - New — 15 tests, 29/29 total suite pass - Updated — re-exports the new adapter - Updated — adds 'codex' format case - Updated — adds importFromCodex() for 'codex' source ## Why Codex CLI (openai/codex) is OpenAI's open-source terminal coding agent. Gitagent can now round-trip agents to/from Codex CLI format. ## Format mapping | gitagent | Codex CLI | |---------------------|----------------------| | agent.yaml + SOUL.md + RULES.md | AGENTS.md | | model.preferred | codex.json model | | model → claude/gemini | provider: openai-compatible | | model → llama/mistral | provider: ollama | | model → gpt/o-series | (default, no provider field) | ## Export ``` gitagent export --format codex ``` Emits: - AGENTS.md — identity (SOUL.md), rules (RULES.md), skills, compliance - codex.json — { model, provider? } ## Import ``` gitagent import --from codex ``` Reads AGENTS.md + codex.json, writes: - agent.yaml — name, model (from codex.json) - SOUL.md — all non-rule sections - RULES.md — rule/constraint/compliance sections (when present) ## Tests 15 new tests covering: struct shape, SOUL/RULES/skill content, config model/provider inference for OpenAI/o-series/Claude/Llama/Mistral, string export section headers, JSON validity. --- src/adapters/codex.test.ts | 180 ++++++++++++++++++++++++++++++++ src/adapters/codex.ts | 203 +++++++++++++++++++++++++++++++++++++ src/adapters/index.ts | 1 + src/commands/export.ts | 8 +- src/commands/import.ts | 86 +++++++++++++++- 5 files changed, 474 insertions(+), 4 deletions(-) create mode 100644 src/adapters/codex.test.ts create mode 100644 src/adapters/codex.ts diff --git a/src/adapters/codex.test.ts b/src/adapters/codex.test.ts new file mode 100644 index 0000000..4d505d7 --- /dev/null +++ b/src/adapters/codex.test.ts @@ -0,0 +1,180 @@ +/** + * Tests for the Codex CLI adapter (export + import). + * + * Uses Node.js built-in test runner (node --test). + */ +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { exportToCodex, exportToCodexString } from './codex.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeAgentDir(opts: { + name?: string; + description?: string; + soul?: string; + rules?: string; + model?: string; + skills?: Array<{ name: string; description: string; instructions: string }>; +}): string { + const dir = mkdtempSync(join(tmpdir(), 'gitagent-codex-test-')); + + const modelBlock = opts.model + ? `model:\n preferred: ${opts.model}\n` + : ''; + + writeFileSync( + join(dir, 'agent.yaml'), + `spec_version: '0.1.0'\nname: ${opts.name ?? 'test-agent'}\nversion: '0.1.0'\ndescription: '${opts.description ?? 'A test agent'}'\n${modelBlock}`, + 'utf-8', + ); + + if (opts.soul !== undefined) { + writeFileSync(join(dir, 'SOUL.md'), opts.soul, 'utf-8'); + } + + if (opts.rules !== undefined) { + writeFileSync(join(dir, 'RULES.md'), opts.rules, 'utf-8'); + } + + if (opts.skills) { + for (const skill of opts.skills) { + const skillDir = join(dir, 'skills', skill.name); + mkdirSync(skillDir, { recursive: true }); + writeFileSync( + join(skillDir, 'SKILL.md'), + `---\nname: ${skill.name}\ndescription: '${skill.description}'\n---\n\n${skill.instructions}\n`, + 'utf-8', + ); + } + } + + return dir; +} + +// --------------------------------------------------------------------------- +// exportToCodex +// --------------------------------------------------------------------------- + +describe('exportToCodex', () => { + test('produces instructions and config objects', () => { + const dir = makeAgentDir({ name: 'my-agent', description: 'My test agent' }); + const result = exportToCodex(dir); + assert.ok(typeof result.instructions === 'string'); + assert.ok(typeof result.config === 'object'); + }); + + test('instructions include agent name and description', () => { + const dir = makeAgentDir({ name: 'demo-agent', description: 'Demo description' }); + const { instructions } = exportToCodex(dir); + assert.match(instructions, /demo-agent/); + assert.match(instructions, /Demo description/); + }); + + test('instructions include SOUL.md content', () => { + const dir = makeAgentDir({ soul: '# Soul\n\nBe helpful and precise.' }); + const { instructions } = exportToCodex(dir); + assert.match(instructions, /Be helpful and precise/); + }); + + test('instructions include RULES.md content', () => { + const dir = makeAgentDir({ rules: '# Rules\n\nNever share credentials.' }); + const { instructions } = exportToCodex(dir); + assert.match(instructions, /Never share credentials/); + }); + + test('instructions include skill content', () => { + const dir = makeAgentDir({ + skills: [ + { name: 'web-search', description: 'Search the web', instructions: 'Use the search tool.' }, + ], + }); + const { instructions } = exportToCodex(dir); + assert.match(instructions, /web-search/); + assert.match(instructions, /Use the search tool/); + }); + + test('config is empty when no model is set', () => { + const dir = makeAgentDir({}); + const { config } = exportToCodex(dir); + assert.deepEqual(config, {}); + }); + + test('config.model set for OpenAI models (no provider emitted)', () => { + const dir = makeAgentDir({ model: 'gpt-4o' }); + const { config } = exportToCodex(dir); + assert.equal(config.model, 'gpt-4o'); + assert.equal(config.provider, undefined); + }); + + test('config.model set for o-series models (no provider emitted)', () => { + const dir = makeAgentDir({ model: 'o3-mini' }); + const { config } = exportToCodex(dir); + assert.equal(config.model, 'o3-mini'); + assert.equal(config.provider, undefined); + }); + + test('config.provider is openai-compatible for claude models', () => { + const dir = makeAgentDir({ model: 'claude-sonnet-4-5' }); + const { config } = exportToCodex(dir); + assert.equal(config.model, 'claude-sonnet-4-5'); + assert.equal(config.provider, 'openai-compatible'); + }); + + test('config.provider is ollama for llama models', () => { + const dir = makeAgentDir({ model: 'llama3.1' }); + const { config } = exportToCodex(dir); + assert.equal(config.model, 'llama3.1'); + assert.equal(config.provider, 'ollama'); + }); + + test('config.provider is ollama for mistral models', () => { + const dir = makeAgentDir({ model: 'mistral-7b' }); + const { config } = exportToCodex(dir); + assert.equal(config.model, 'mistral-7b'); + assert.equal(config.provider, 'ollama'); + }); +}); + +// --------------------------------------------------------------------------- +// exportToCodexString +// --------------------------------------------------------------------------- + +describe('exportToCodexString', () => { + test('contains AGENTS.md and codex.json section headers', () => { + const dir = makeAgentDir({ name: 'str-agent', description: 'String export test' }); + const result = exportToCodexString(dir); + assert.match(result, /=== AGENTS\.md ===/); + assert.match(result, /=== codex\.json ===/); + }); + + test('contains agent name in output', () => { + const dir = makeAgentDir({ name: 'string-agent', description: 'desc' }); + const result = exportToCodexString(dir); + assert.match(result, /string-agent/); + }); + + test('codex.json section is valid JSON', () => { + const dir = makeAgentDir({ model: 'gpt-4o' }); + const result = exportToCodexString(dir); + const jsonStart = result.indexOf('# === codex.json ===\n') + '# === codex.json ===\n'.length; + const jsonStr = result.slice(jsonStart).trim(); + assert.doesNotThrow(() => JSON.parse(jsonStr)); + const parsed = JSON.parse(jsonStr); + assert.equal(parsed.model, 'gpt-4o'); + }); + + test('codex.json is {} when no model set', () => { + const dir = makeAgentDir({}); + const result = exportToCodexString(dir); + const jsonStart = result.indexOf('# === codex.json ===\n') + '# === codex.json ===\n'.length; + const jsonStr = result.slice(jsonStart).trim(); + assert.deepEqual(JSON.parse(jsonStr), {}); + }); +}); diff --git a/src/adapters/codex.ts b/src/adapters/codex.ts new file mode 100644 index 0000000..bddf05f --- /dev/null +++ b/src/adapters/codex.ts @@ -0,0 +1,203 @@ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import yaml from 'js-yaml'; +import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js'; +import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js'; +import { buildComplianceSection } from './shared.js'; + +/** + * Export a gitagent to OpenAI Codex CLI format. + * + * Codex CLI (openai/codex) uses: + * - AGENTS.md (custom agent instructions, project root) + * - codex.json (model and provider configuration) + * + * Reference: https://github.com/openai/codex + */ +export interface CodexExport { + /** Content for AGENTS.md */ + instructions: string; + /** Content for codex.json */ + config: Record; +} + +/** + * Export a gitagent directory to Codex CLI format. + */ +export function exportToCodex(dir: string): CodexExport { + const agentDir = resolve(dir); + const manifest = loadAgentManifest(agentDir); + + const instructions = buildInstructions(agentDir, manifest); + const config = buildConfig(manifest); + + return { instructions, config }; +} + +/** + * Export as a single string (for `gitagent export -f codex`). + */ +export function exportToCodexString(dir: string): string { + const exp = exportToCodex(dir); + const parts: string[] = []; + + parts.push('# === AGENTS.md ==='); + parts.push(exp.instructions); + parts.push('\n# === codex.json ==='); + parts.push(JSON.stringify(exp.config, null, 2)); + + return parts.join('\n'); +} + +function buildInstructions( + agentDir: string, + manifest: ReturnType, +): string { + const parts: string[] = []; + + // Agent identity + parts.push(`# ${manifest.name}`); + parts.push(`${manifest.description}`); + parts.push(''); + + // SOUL.md — persona / identity + const soul = loadFileIfExists(join(agentDir, 'SOUL.md')); + if (soul) { + parts.push(soul); + parts.push(''); + } + + // RULES.md — constraints and operational rules + const rules = loadFileIfExists(join(agentDir, 'RULES.md')); + if (rules) { + parts.push(rules); + parts.push(''); + } + + // DUTIES.md — segregation of duties policy + const duty = loadFileIfExists(join(agentDir, 'DUTIES.md')); + if (duty) { + parts.push(duty); + parts.push(''); + } + + // Skills — include full instructions (Codex reads AGENTS.md as a single file) + const skillsDir = join(agentDir, 'skills'); + const skills = loadAllSkills(skillsDir); + if (skills.length > 0) { + parts.push('## Skills'); + parts.push(''); + for (const skill of skills) { + const toolsList = getAllowedTools(skill.frontmatter); + const toolsNote = toolsList.length > 0 ? `\nAllowed tools: ${toolsList.join(', ')}` : ''; + parts.push(`### ${skill.frontmatter.name}`); + parts.push(`${skill.frontmatter.description}${toolsNote}`); + parts.push(''); + parts.push(skill.instructions); + parts.push(''); + } + } + + // Tools + const toolsDir = join(agentDir, 'tools'); + if (existsSync(toolsDir)) { + const toolFiles = readdirSync(toolsDir).filter(f => f.endsWith('.yaml')); + if (toolFiles.length > 0) { + parts.push('## Tools'); + parts.push(''); + for (const file of toolFiles) { + try { + const content = readFileSync(join(toolsDir, file), 'utf-8'); + const toolConfig = yaml.load(content) as { + name?: string; + description?: string; + input_schema?: Record; + }; + if (toolConfig?.name) { + parts.push(`### ${toolConfig.name}`); + if (toolConfig.description) { + parts.push(toolConfig.description); + } + if (toolConfig.input_schema) { + parts.push(''); + parts.push('```yaml'); + parts.push(yaml.dump(toolConfig.input_schema).trimEnd()); + parts.push('```'); + } + parts.push(''); + } + } catch { /* skip malformed tools */ } + } + } + } + + // Knowledge (always_load documents) + const knowledgeDir = join(agentDir, 'knowledge'); + const indexPath = join(knowledgeDir, 'index.yaml'); + if (existsSync(indexPath)) { + const index = yaml.load(readFileSync(indexPath, 'utf-8')) as { + documents?: Array<{ path: string; always_load?: boolean }>; + }; + + if (index.documents) { + const alwaysLoad = index.documents.filter(d => d.always_load); + if (alwaysLoad.length > 0) { + parts.push('## Knowledge'); + parts.push(''); + for (const doc of alwaysLoad) { + const content = loadFileIfExists(join(knowledgeDir, doc.path)); + if (content) { + parts.push(`### ${doc.path}`); + parts.push(content); + parts.push(''); + } + } + } + } + } + + // Compliance constraints + if (manifest.compliance) { + const constraints = buildComplianceSection(manifest.compliance); + if (constraints) { + parts.push(constraints); + parts.push(''); + } + } + + return parts.join('\n').trimEnd() + '\n'; +} + +function buildConfig(manifest: ReturnType): Record { + const config: Record = {}; + + // Map model preference to Codex CLI model format + // Codex CLI config.json accepts: { model: "string", provider?: "openai|azure|..." } + if (manifest.model?.preferred) { + const model = manifest.model.preferred; + config.model = model; + + // Add provider hint when it can be inferred from the model name + const provider = inferProvider(model); + if (provider !== 'openai') { + // Only emit provider when non-default — Codex defaults to openai + config.provider = provider; + } + } + + return config; +} + +/** + * Infer the Codex CLI provider name from a model identifier. + * Codex CLI providers: openai (default), azure, ollama, openai-compatible + */ +function inferProvider(model: string): string { + if (model.startsWith('claude') || model.includes('anthropic')) return 'openai-compatible'; + if (model.startsWith('gemini') || model.includes('google')) return 'openai-compatible'; + if (model.startsWith('deepseek')) return 'openai-compatible'; + if (model.startsWith('llama') || model.startsWith('mistral') || model.startsWith('qwen')) return 'ollama'; + if (model.startsWith('o1') || model.startsWith('o3') || model.startsWith('o4') || model.startsWith('gpt')) return 'openai'; + if (model.startsWith('codex')) return 'openai'; + return 'openai'; +} diff --git a/src/adapters/index.ts b/src/adapters/index.ts index e5532e9..d4bb203 100644 --- a/src/adapters/index.ts +++ b/src/adapters/index.ts @@ -7,3 +7,4 @@ export { exportToNanobotString, exportToNanobot } from './nanobot.js'; export { exportToCopilotString, exportToCopilot } from './copilot.js'; export { exportToOpenCodeString, exportToOpenCode } from './opencode.js'; export { exportToCursorString, exportToCursor } from './cursor.js'; +export { exportToCodexString, exportToCodex } from './codex.js'; diff --git a/src/commands/export.ts b/src/commands/export.ts index 22d6637..7fcdb1e 100644 --- a/src/commands/export.ts +++ b/src/commands/export.ts @@ -11,6 +11,7 @@ import { exportToCopilotString, exportToOpenCodeString, exportToCursorString, + exportToCodexString, } from '../adapters/index.js'; import { exportToLyzrString } from '../adapters/lyzr.js'; import { exportToGitHubString } from '../adapters/github.js'; @@ -23,7 +24,7 @@ interface ExportOptions { export const exportCommand = new Command('export') .description('Export agent to other formats') - .requiredOption('-f, --format ', 'Export format (system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor)') + .requiredOption('-f, --format ', 'Export format (system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor, codex)') .option('-d, --dir ', 'Agent directory', '.') .option('-o, --output ', 'Output file path') .action(async (options: ExportOptions) => { @@ -69,9 +70,12 @@ export const exportCommand = new Command('export') case 'cursor': result = exportToCursorString(dir); break; + case 'codex': + result = exportToCodexString(dir); + break; default: error(`Unknown format: ${options.format}`); - info('Supported formats: system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor'); + info('Supported formats: system-prompt, claude-code, openai, crewai, openclaw, nanobot, lyzr, github, copilot, opencode, cursor, codex'); process.exit(1); } diff --git a/src/commands/import.ts b/src/commands/import.ts index db5cdbb..e83fea7 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -252,6 +252,85 @@ function importFromCrewAI(sourcePath: string, targetDir: string): void { } } +function importFromCodex(sourcePath: string, targetDir: string): void { + const sourceDir = resolve(sourcePath); + + // Codex CLI uses: + // AGENTS.md — custom instructions (project root) + // codex.json — model/provider config + const agentsMdPath = join(sourceDir, 'AGENTS.md'); + const configPath = join(sourceDir, 'codex.json'); + + let instructions = ''; + let config: Record = {}; + + if (existsSync(agentsMdPath)) { + instructions = readFileSync(agentsMdPath, 'utf-8'); + info('Found AGENTS.md'); + } else { + throw new Error('No AGENTS.md found in source directory'); + } + + if (existsSync(configPath)) { + try { + config = JSON.parse(readFileSync(configPath, 'utf-8')); + info('Found codex.json'); + } catch { /* ignore malformed config */ } + } + + const dirName = basename(sourceDir); + + // codex.json model format: "model-id" (no provider/ prefix, unlike opencode) + const rawModel = (config.model as string) || undefined; + const agentYaml: Record = { + spec_version: '0.1.0', + name: dirName.toLowerCase().replace(/[^a-z0-9-]/g, '-'), + version: '0.1.0', + description: `Imported from Codex CLI project: ${dirName}`, + }; + if (rawModel) { + agentYaml.model = { preferred: rawModel }; + } + + writeFileSync(join(targetDir, 'agent.yaml'), yaml.dump(agentYaml), 'utf-8'); + success('Created agent.yaml'); + + // Convert AGENTS.md to SOUL.md (+ optional RULES.md) + const sections = parseSections(instructions); + let soulContent = '# Soul\n\n'; + let rulesContent = '# Rules\n\n'; + let hasRules = false; + + for (const [title, content] of sections) { + const lower = title.toLowerCase(); + if ( + lower.includes('rule') || + lower.includes('constraint') || + lower.includes('never') || + lower.includes('always') || + lower.includes('must') || + lower.includes('compliance') + ) { + rulesContent += `## ${title}\n${content}\n\n`; + hasRules = true; + } else { + soulContent += `## ${title}\n${content}\n\n`; + } + } + + if (sections.length === 0) { + soulContent += instructions; + } + + writeFileSync(join(targetDir, 'SOUL.md'), soulContent, 'utf-8'); + success('Created SOUL.md'); + + if (hasRules) { + writeFileSync(join(targetDir, 'RULES.md'), rulesContent, 'utf-8'); + success('Created RULES.md'); + } +} + function importFromOpenCode(sourcePath: string, targetDir: string): void { const sourceDir = resolve(sourcePath); @@ -351,7 +430,7 @@ function parseSections(markdown: string): [string, string][] { export const importCommand = new Command('import') .description('Import from other agent formats') - .requiredOption('--from ', 'Source format (claude, cursor, crewai, opencode)') + .requiredOption('--from ', 'Source format (claude, cursor, crewai, opencode, codex)') .argument('', 'Source file or directory path') .option('-d, --dir ', 'Target directory', '.') .action((sourcePath: string, options: ImportOptions) => { @@ -375,9 +454,12 @@ export const importCommand = new Command('import') case 'opencode': importFromOpenCode(sourcePath, targetDir); break; + case 'codex': + importFromCodex(sourcePath, targetDir); + break; default: error(`Unknown format: ${options.from}`); - info('Supported formats: claude, cursor, crewai, opencode'); + info('Supported formats: claude, cursor, crewai, opencode, codex'); process.exit(1); }