Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
180 changes: 180 additions & 0 deletions src/adapters/codex.test.ts
Original file line number Diff line number Diff line change
@@ -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), {});
});
});
203 changes: 203 additions & 0 deletions src/adapters/codex.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
}

/**
* 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<typeof loadAgentManifest>,
): 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<string, unknown>;
};
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<typeof loadAgentManifest>): Record<string, unknown> {
const config: Record<string, unknown> = {};

// 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';
}
1 change: 1 addition & 0 deletions src/adapters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading