Skip to content

Commit fdd07a3

Browse files
committed
feat(adapters): Codex CLI adapter — export + import (#32)
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 <path> ``` 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.
1 parent a6a4fa9 commit fdd07a3

File tree

5 files changed

+474
-4
lines changed

5 files changed

+474
-4
lines changed

src/adapters/codex.test.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
/**
2+
* Tests for the Codex CLI adapter (export + import).
3+
*
4+
* Uses Node.js built-in test runner (node --test).
5+
*/
6+
import { test, describe } from 'node:test';
7+
import assert from 'node:assert/strict';
8+
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
9+
import { join } from 'node:path';
10+
import { tmpdir } from 'node:os';
11+
12+
import { exportToCodex, exportToCodexString } from './codex.js';
13+
14+
// ---------------------------------------------------------------------------
15+
// Helpers
16+
// ---------------------------------------------------------------------------
17+
18+
function makeAgentDir(opts: {
19+
name?: string;
20+
description?: string;
21+
soul?: string;
22+
rules?: string;
23+
model?: string;
24+
skills?: Array<{ name: string; description: string; instructions: string }>;
25+
}): string {
26+
const dir = mkdtempSync(join(tmpdir(), 'gitagent-codex-test-'));
27+
28+
const modelBlock = opts.model
29+
? `model:\n preferred: ${opts.model}\n`
30+
: '';
31+
32+
writeFileSync(
33+
join(dir, 'agent.yaml'),
34+
`spec_version: '0.1.0'\nname: ${opts.name ?? 'test-agent'}\nversion: '0.1.0'\ndescription: '${opts.description ?? 'A test agent'}'\n${modelBlock}`,
35+
'utf-8',
36+
);
37+
38+
if (opts.soul !== undefined) {
39+
writeFileSync(join(dir, 'SOUL.md'), opts.soul, 'utf-8');
40+
}
41+
42+
if (opts.rules !== undefined) {
43+
writeFileSync(join(dir, 'RULES.md'), opts.rules, 'utf-8');
44+
}
45+
46+
if (opts.skills) {
47+
for (const skill of opts.skills) {
48+
const skillDir = join(dir, 'skills', skill.name);
49+
mkdirSync(skillDir, { recursive: true });
50+
writeFileSync(
51+
join(skillDir, 'SKILL.md'),
52+
`---\nname: ${skill.name}\ndescription: '${skill.description}'\n---\n\n${skill.instructions}\n`,
53+
'utf-8',
54+
);
55+
}
56+
}
57+
58+
return dir;
59+
}
60+
61+
// ---------------------------------------------------------------------------
62+
// exportToCodex
63+
// ---------------------------------------------------------------------------
64+
65+
describe('exportToCodex', () => {
66+
test('produces instructions and config objects', () => {
67+
const dir = makeAgentDir({ name: 'my-agent', description: 'My test agent' });
68+
const result = exportToCodex(dir);
69+
assert.ok(typeof result.instructions === 'string');
70+
assert.ok(typeof result.config === 'object');
71+
});
72+
73+
test('instructions include agent name and description', () => {
74+
const dir = makeAgentDir({ name: 'demo-agent', description: 'Demo description' });
75+
const { instructions } = exportToCodex(dir);
76+
assert.match(instructions, /demo-agent/);
77+
assert.match(instructions, /Demo description/);
78+
});
79+
80+
test('instructions include SOUL.md content', () => {
81+
const dir = makeAgentDir({ soul: '# Soul\n\nBe helpful and precise.' });
82+
const { instructions } = exportToCodex(dir);
83+
assert.match(instructions, /Be helpful and precise/);
84+
});
85+
86+
test('instructions include RULES.md content', () => {
87+
const dir = makeAgentDir({ rules: '# Rules\n\nNever share credentials.' });
88+
const { instructions } = exportToCodex(dir);
89+
assert.match(instructions, /Never share credentials/);
90+
});
91+
92+
test('instructions include skill content', () => {
93+
const dir = makeAgentDir({
94+
skills: [
95+
{ name: 'web-search', description: 'Search the web', instructions: 'Use the search tool.' },
96+
],
97+
});
98+
const { instructions } = exportToCodex(dir);
99+
assert.match(instructions, /web-search/);
100+
assert.match(instructions, /Use the search tool/);
101+
});
102+
103+
test('config is empty when no model is set', () => {
104+
const dir = makeAgentDir({});
105+
const { config } = exportToCodex(dir);
106+
assert.deepEqual(config, {});
107+
});
108+
109+
test('config.model set for OpenAI models (no provider emitted)', () => {
110+
const dir = makeAgentDir({ model: 'gpt-4o' });
111+
const { config } = exportToCodex(dir);
112+
assert.equal(config.model, 'gpt-4o');
113+
assert.equal(config.provider, undefined);
114+
});
115+
116+
test('config.model set for o-series models (no provider emitted)', () => {
117+
const dir = makeAgentDir({ model: 'o3-mini' });
118+
const { config } = exportToCodex(dir);
119+
assert.equal(config.model, 'o3-mini');
120+
assert.equal(config.provider, undefined);
121+
});
122+
123+
test('config.provider is openai-compatible for claude models', () => {
124+
const dir = makeAgentDir({ model: 'claude-sonnet-4-5' });
125+
const { config } = exportToCodex(dir);
126+
assert.equal(config.model, 'claude-sonnet-4-5');
127+
assert.equal(config.provider, 'openai-compatible');
128+
});
129+
130+
test('config.provider is ollama for llama models', () => {
131+
const dir = makeAgentDir({ model: 'llama3.1' });
132+
const { config } = exportToCodex(dir);
133+
assert.equal(config.model, 'llama3.1');
134+
assert.equal(config.provider, 'ollama');
135+
});
136+
137+
test('config.provider is ollama for mistral models', () => {
138+
const dir = makeAgentDir({ model: 'mistral-7b' });
139+
const { config } = exportToCodex(dir);
140+
assert.equal(config.model, 'mistral-7b');
141+
assert.equal(config.provider, 'ollama');
142+
});
143+
});
144+
145+
// ---------------------------------------------------------------------------
146+
// exportToCodexString
147+
// ---------------------------------------------------------------------------
148+
149+
describe('exportToCodexString', () => {
150+
test('contains AGENTS.md and codex.json section headers', () => {
151+
const dir = makeAgentDir({ name: 'str-agent', description: 'String export test' });
152+
const result = exportToCodexString(dir);
153+
assert.match(result, /=== AGENTS\.md ===/);
154+
assert.match(result, /=== codex\.json ===/);
155+
});
156+
157+
test('contains agent name in output', () => {
158+
const dir = makeAgentDir({ name: 'string-agent', description: 'desc' });
159+
const result = exportToCodexString(dir);
160+
assert.match(result, /string-agent/);
161+
});
162+
163+
test('codex.json section is valid JSON', () => {
164+
const dir = makeAgentDir({ model: 'gpt-4o' });
165+
const result = exportToCodexString(dir);
166+
const jsonStart = result.indexOf('# === codex.json ===\n') + '# === codex.json ===\n'.length;
167+
const jsonStr = result.slice(jsonStart).trim();
168+
assert.doesNotThrow(() => JSON.parse(jsonStr));
169+
const parsed = JSON.parse(jsonStr);
170+
assert.equal(parsed.model, 'gpt-4o');
171+
});
172+
173+
test('codex.json is {} when no model set', () => {
174+
const dir = makeAgentDir({});
175+
const result = exportToCodexString(dir);
176+
const jsonStart = result.indexOf('# === codex.json ===\n') + '# === codex.json ===\n'.length;
177+
const jsonStr = result.slice(jsonStart).trim();
178+
assert.deepEqual(JSON.parse(jsonStr), {});
179+
});
180+
});

src/adapters/codex.ts

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
2+
import { join, resolve } from 'node:path';
3+
import yaml from 'js-yaml';
4+
import { loadAgentManifest, loadFileIfExists } from '../utils/loader.js';
5+
import { loadAllSkills, getAllowedTools } from '../utils/skill-loader.js';
6+
import { buildComplianceSection } from './shared.js';
7+
8+
/**
9+
* Export a gitagent to OpenAI Codex CLI format.
10+
*
11+
* Codex CLI (openai/codex) uses:
12+
* - AGENTS.md (custom agent instructions, project root)
13+
* - codex.json (model and provider configuration)
14+
*
15+
* Reference: https://github.com/openai/codex
16+
*/
17+
export interface CodexExport {
18+
/** Content for AGENTS.md */
19+
instructions: string;
20+
/** Content for codex.json */
21+
config: Record<string, unknown>;
22+
}
23+
24+
/**
25+
* Export a gitagent directory to Codex CLI format.
26+
*/
27+
export function exportToCodex(dir: string): CodexExport {
28+
const agentDir = resolve(dir);
29+
const manifest = loadAgentManifest(agentDir);
30+
31+
const instructions = buildInstructions(agentDir, manifest);
32+
const config = buildConfig(manifest);
33+
34+
return { instructions, config };
35+
}
36+
37+
/**
38+
* Export as a single string (for `gitagent export -f codex`).
39+
*/
40+
export function exportToCodexString(dir: string): string {
41+
const exp = exportToCodex(dir);
42+
const parts: string[] = [];
43+
44+
parts.push('# === AGENTS.md ===');
45+
parts.push(exp.instructions);
46+
parts.push('\n# === codex.json ===');
47+
parts.push(JSON.stringify(exp.config, null, 2));
48+
49+
return parts.join('\n');
50+
}
51+
52+
function buildInstructions(
53+
agentDir: string,
54+
manifest: ReturnType<typeof loadAgentManifest>,
55+
): string {
56+
const parts: string[] = [];
57+
58+
// Agent identity
59+
parts.push(`# ${manifest.name}`);
60+
parts.push(`${manifest.description}`);
61+
parts.push('');
62+
63+
// SOUL.md — persona / identity
64+
const soul = loadFileIfExists(join(agentDir, 'SOUL.md'));
65+
if (soul) {
66+
parts.push(soul);
67+
parts.push('');
68+
}
69+
70+
// RULES.md — constraints and operational rules
71+
const rules = loadFileIfExists(join(agentDir, 'RULES.md'));
72+
if (rules) {
73+
parts.push(rules);
74+
parts.push('');
75+
}
76+
77+
// DUTIES.md — segregation of duties policy
78+
const duty = loadFileIfExists(join(agentDir, 'DUTIES.md'));
79+
if (duty) {
80+
parts.push(duty);
81+
parts.push('');
82+
}
83+
84+
// Skills — include full instructions (Codex reads AGENTS.md as a single file)
85+
const skillsDir = join(agentDir, 'skills');
86+
const skills = loadAllSkills(skillsDir);
87+
if (skills.length > 0) {
88+
parts.push('## Skills');
89+
parts.push('');
90+
for (const skill of skills) {
91+
const toolsList = getAllowedTools(skill.frontmatter);
92+
const toolsNote = toolsList.length > 0 ? `\nAllowed tools: ${toolsList.join(', ')}` : '';
93+
parts.push(`### ${skill.frontmatter.name}`);
94+
parts.push(`${skill.frontmatter.description}${toolsNote}`);
95+
parts.push('');
96+
parts.push(skill.instructions);
97+
parts.push('');
98+
}
99+
}
100+
101+
// Tools
102+
const toolsDir = join(agentDir, 'tools');
103+
if (existsSync(toolsDir)) {
104+
const toolFiles = readdirSync(toolsDir).filter(f => f.endsWith('.yaml'));
105+
if (toolFiles.length > 0) {
106+
parts.push('## Tools');
107+
parts.push('');
108+
for (const file of toolFiles) {
109+
try {
110+
const content = readFileSync(join(toolsDir, file), 'utf-8');
111+
const toolConfig = yaml.load(content) as {
112+
name?: string;
113+
description?: string;
114+
input_schema?: Record<string, unknown>;
115+
};
116+
if (toolConfig?.name) {
117+
parts.push(`### ${toolConfig.name}`);
118+
if (toolConfig.description) {
119+
parts.push(toolConfig.description);
120+
}
121+
if (toolConfig.input_schema) {
122+
parts.push('');
123+
parts.push('```yaml');
124+
parts.push(yaml.dump(toolConfig.input_schema).trimEnd());
125+
parts.push('```');
126+
}
127+
parts.push('');
128+
}
129+
} catch { /* skip malformed tools */ }
130+
}
131+
}
132+
}
133+
134+
// Knowledge (always_load documents)
135+
const knowledgeDir = join(agentDir, 'knowledge');
136+
const indexPath = join(knowledgeDir, 'index.yaml');
137+
if (existsSync(indexPath)) {
138+
const index = yaml.load(readFileSync(indexPath, 'utf-8')) as {
139+
documents?: Array<{ path: string; always_load?: boolean }>;
140+
};
141+
142+
if (index.documents) {
143+
const alwaysLoad = index.documents.filter(d => d.always_load);
144+
if (alwaysLoad.length > 0) {
145+
parts.push('## Knowledge');
146+
parts.push('');
147+
for (const doc of alwaysLoad) {
148+
const content = loadFileIfExists(join(knowledgeDir, doc.path));
149+
if (content) {
150+
parts.push(`### ${doc.path}`);
151+
parts.push(content);
152+
parts.push('');
153+
}
154+
}
155+
}
156+
}
157+
}
158+
159+
// Compliance constraints
160+
if (manifest.compliance) {
161+
const constraints = buildComplianceSection(manifest.compliance);
162+
if (constraints) {
163+
parts.push(constraints);
164+
parts.push('');
165+
}
166+
}
167+
168+
return parts.join('\n').trimEnd() + '\n';
169+
}
170+
171+
function buildConfig(manifest: ReturnType<typeof loadAgentManifest>): Record<string, unknown> {
172+
const config: Record<string, unknown> = {};
173+
174+
// Map model preference to Codex CLI model format
175+
// Codex CLI config.json accepts: { model: "string", provider?: "openai|azure|..." }
176+
if (manifest.model?.preferred) {
177+
const model = manifest.model.preferred;
178+
config.model = model;
179+
180+
// Add provider hint when it can be inferred from the model name
181+
const provider = inferProvider(model);
182+
if (provider !== 'openai') {
183+
// Only emit provider when non-default — Codex defaults to openai
184+
config.provider = provider;
185+
}
186+
}
187+
188+
return config;
189+
}
190+
191+
/**
192+
* Infer the Codex CLI provider name from a model identifier.
193+
* Codex CLI providers: openai (default), azure, ollama, openai-compatible
194+
*/
195+
function inferProvider(model: string): string {
196+
if (model.startsWith('claude') || model.includes('anthropic')) return 'openai-compatible';
197+
if (model.startsWith('gemini') || model.includes('google')) return 'openai-compatible';
198+
if (model.startsWith('deepseek')) return 'openai-compatible';
199+
if (model.startsWith('llama') || model.startsWith('mistral') || model.startsWith('qwen')) return 'ollama';
200+
if (model.startsWith('o1') || model.startsWith('o3') || model.startsWith('o4') || model.startsWith('gpt')) return 'openai';
201+
if (model.startsWith('codex')) return 'openai';
202+
return 'openai';
203+
}

src/adapters/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ export { exportToNanobotString, exportToNanobot } from './nanobot.js';
77
export { exportToCopilotString, exportToCopilot } from './copilot.js';
88
export { exportToOpenCodeString, exportToOpenCode } from './opencode.js';
99
export { exportToCursorString, exportToCursor } from './cursor.js';
10+
export { exportToCodexString, exportToCodex } from './codex.js';

0 commit comments

Comments
 (0)