记一次 Codex 和 Claude Code 共用项目规范
项目里我主要还是用 Claude Code CLI。它的目录规范很清楚:CLAUDE.md、.claude/skills、.claude/agents,再加上用户目录下面的 ~/.claude。
但我现在也会在 Codex 里干活,于是就冒出一个很现实的问题:
一个项目同时给 Claude Code 和 Codex 用,skills 和 agents 到底放哪?
一开始我还想过软链接。后来发现不太靠谱,尤其项目要同时在 Win11 和 macOS 上开发,软链接这东西很容易变成另一个坑。
最后定下来的思路
我最后把 Claude Code 的目录当成源。
也就是:
text
CLAUDE.md
.claude/
skills/
agents/
Codex 这边只做本地镜像。
text
.codex/
config.toml
hooks.json
hooks/
sync-claude-to-codex.mjs
agents/
.gitkeep
*.toml # 生成,不提交
.agents/
skills/ # 生成,不提交
CLAUDE_BRIDGE.md # 生成,不提交
claude-sync-manifest.json
简单说就是:
.claude是源头.codex放 Codex 的 hook 配置和脚本.agents/skills放 Codex 能读取的项目级 skills.codex/agents/*.toml放 Codex 能读取的项目级 agents- 生成物都不提交
这样团队成员如果不用 Codex,也不会被这些生成文件污染。
Hook 配置
Codex 的 Hook 不是随便放个脚本就行,还是要写 .codex/hooks.json。
我现在的配置大概是这样:
json
{
"description": "Sync Claude Code project skills and agents into local Codex bridge output.",
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node .codex/hooks/sync-claude-to-codex.mjs",
"timeout": 30
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node .codex/hooks/sync-claude-to-codex.mjs",
"timeout": 30
}
]
}
]
}
}
SessionStart 是启动时同步一次。
后来我又加了 UserPromptSubmit,因为我发现有时候启动时没看到生成结果,或者生成顺序比 Codex 扫描 skills 晚。加上这个之后,至少提交第一条提示词时还能补一次。
还有一个小坑,feature flag 现在要写:
toml
[features]
hooks = true
之前的 codex_hooks = true 已经过期了,会有 warning。
完整同步脚本
下面是完整的 sync-claude-to-codex.mjs 脚本,可以直接复制使用:
javascript
import { cp, mkdir, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const hookDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(hookDir, "..", "..");
const claudeRoot = path.join(repoRoot, ".claude");
const codexRoot = path.join(repoRoot, ".codex");
const bridgeRoot = path.join(repoRoot, ".agents");
const codexSkillsRoot = path.join(repoRoot, ".agents", "skills");
const codexAgentsRoot = path.join(codexRoot, "agents");
const manifestPath = path.join(bridgeRoot, "claude-sync-manifest.json");
const generatedMarker = "Generated by .codex/hooks/sync-claude-to-codex.mjs";
async function exists(targetPath) {
try {
await stat(targetPath);
return true;
} catch {
return false;
}
}
async function readJson(targetPath, fallback) {
try {
return JSON.parse(await readFile(targetPath, "utf8"));
} catch {
return fallback;
}
}
function slugify(value) {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+|-+$/g, "");
}
function parseFrontmatter(markdown) {
const match = markdown.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
if (!match) {
return { body: markdown, fields: {} };
}
const fields = {};
for (const line of match[1].split(/\r?\n/)) {
const field = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (!field) continue;
fields[field[1]] = field[2].replace(/^["']|["']$/g, "");
}
return {
body: markdown.slice(match[0].length),
fields,
};
}
async function listDirs(targetPath) {
if (!(await exists(targetPath))) return [];
const entries = await readdir(targetPath, { withFileTypes: true });
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
}
async function syncSkill(skillName) {
const sourceDir = path.join(claudeRoot, "skills", skillName);
const sourceSkill = path.join(sourceDir, "SKILL.md");
if (!(await exists(sourceSkill))) return null;
const rawSkill = await readFile(sourceSkill, "utf8");
const { body, fields } = parseFrontmatter(rawSkill);
const codexSkillName = slugify(fields.name ?? skillName);
const targetDir = path.join(codexSkillsRoot, codexSkillName);
await rm(targetDir, { recursive: true, force: true });
await cp(sourceDir, targetDir, { recursive: true });
const description =
fields.description ??
`Project-local skill generated from .claude/skills/${skillName}.`;
const generatedSkill = `---\nname: ${codexSkillName}\ndescription: "${description.replaceAll('"', '\\"')}"\n---\n\n<!-- ${generatedMarker} -->\n<!-- Source: .claude/skills/${skillName}/SKILL.md -->\n<!-- Do not edit this generated copy. Edit the Claude source and rerun pnpm codex:sync-claude. -->\n\n${body.trim()}\n`;
await writeFile(path.join(targetDir, "SKILL.md"), generatedSkill, "utf8");
return {
kind: "skill",
source: `.claude/skills/${skillName}`,
target: `.agents/skills/${codexSkillName}`,
};
}
async function syncAgent(agentFile) {
const sourcePath = path.join(claudeRoot, "agents", agentFile);
const agentName = slugify(path.basename(agentFile, path.extname(agentFile)));
const targetName = `${agentName}.toml`;
const targetPath = path.join(codexAgentsRoot, targetName);
const rawAgent = await readFile(sourcePath, "utf8");
const escapedAgent = rawAgent.trim().replaceAll('"""', '\\"\\"\\"');
const generatedAgent = `# ${generatedMarker}\n# Source: .claude/agents/${agentFile}\n# Do not edit this generated copy. Edit the Claude source and rerun pnpm codex:sync-claude.\n\nname = "${agentName}"\ndescription = "Project-local Codex agent generated from .claude/agents/${agentFile}."\ntools = []\n\ndeveloper_instructions = """\n${escapedAgent}\n"""\n`;
await writeFile(targetPath, generatedAgent, "utf8");
return {
kind: "agent",
source: `.claude/agents/${agentFile}`,
target: `.codex/agents/${targetName}`,
};
}
async function trySyncAgent(agentFile) {
try {
return await syncAgent(agentFile);
} catch (error) {
console.error(`Skipped agent ${agentFile}: ${error.message}`);
return null;
}
}
async function prunePreviousTargets(previousManifest, currentTargets) {
const currentTargetSet = new Set(currentTargets.map((item) => item.target));
for (const item of previousManifest.items ?? []) {
if (!item.target || currentTargetSet.has(item.target)) continue;
if (!item.target.startsWith(".codex/") && !item.target.startsWith(".agents/")) continue;
await rm(path.join(repoRoot, item.target), { recursive: true, force: true });
}
}
async function pruneLegacyPrefixedTargets(currentTargets) {
const currentTargetSet = new Set(currentTargets.map((item) => path.join(repoRoot, item.target)));
const legacyPrefix = `claude-${path.basename(repoRoot).replace(/[^a-zA-Z0-9_-]+/g, "-").toLowerCase()}-`;
for (const skillName of await listDirs(codexSkillsRoot)) {
const targetDir = path.join(codexSkillsRoot, skillName);
if (skillName.startsWith(legacyPrefix) && !currentTargetSet.has(targetDir)) {
await rm(targetDir, { recursive: true, force: true });
}
}
if (await exists(codexAgentsRoot)) {
const entries = await readdir(codexAgentsRoot, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.startsWith(legacyPrefix)) continue;
const targetPath = path.join(codexAgentsRoot, entry.name);
if (!currentTargetSet.has(targetPath)) {
await rm(targetPath, { force: true });
}
}
}
}
async function writeBridgeIndex(items) {
const skillItems = items.filter((item) => item.kind === "skill");
const agentItems = items.filter((item) => item.kind === "agent");
const lines = [
"# Claude to Codex Bridge",
"",
`Generated by \`pnpm codex:sync-claude\`.`,
"",
"## Skills",
"",
...(
skillItems.length
? skillItems.map((item) => `- ${item.source} -> ${item.target}`)
: ["- No Claude skills found."]
),
"",
"## Agents",
"",
...(
agentItems.length
? agentItems.map((item) => `- ${item.source} -> ${item.target}`)
: ["- No Claude agents found."]
),
"",
];
await writeFile(path.join(bridgeRoot, "CLAUDE_BRIDGE.md"), `${lines.join("\n")}\n`, "utf8");
}
async function main() {
await mkdir(bridgeRoot, { recursive: true });
await mkdir(codexSkillsRoot, { recursive: true });
try {
await mkdir(codexAgentsRoot, { recursive: true });
} catch (error) {
console.error(`Unable to prepare ${path.relative(repoRoot, codexAgentsRoot)}: ${error.message}`);
}
const previousManifest = await readJson(manifestPath, { items: [] });
const items = [];
for (const skillName of await listDirs(path.join(claudeRoot, "skills"))) {
const item = await syncSkill(skillName);
if (item) items.push(item);
}
if (await exists(path.join(claudeRoot, "agents"))) {
const agentEntries = await readdir(path.join(claudeRoot, "agents"), { withFileTypes: true });
for (const entry of agentEntries) {
if (entry.isFile() && entry.name.toLowerCase().endsWith(".md")) {
const item = await trySyncAgent(entry.name);
if (item) items.push(item);
}
}
}
await prunePreviousTargets(previousManifest, items);
await pruneLegacyPrefixedTargets(items);
await writeBridgeIndex(items);
await writeFile(
manifestPath,
`${JSON.stringify({ generatedBy: generatedMarker, items }, null, 2)}\n`,
"utf8",
);
console.error(`Synced ${items.length} Claude item(s) into Codex bridge output.`);
console.log("{}");
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
脚本做了什么
- Skills 同步:遍历
.claude/skills/下每个目录,读取SKILL.md的 frontmatter,整个目录复制到.agents/skills/<slugified-name>/,重写 SKILL.md 添加生成标记 - Agents 同步:遍历
.claude/agents/*.md,转换为 Codex 的 TOML 格式,写入.codex/agents/<name>.toml - 清理机制:维护
claude-sync-manifest.json,删除已不存在的旧目标文件 - 遗留处理:清理带
claude-<project>-前缀的旧命名风格文件
名字不要乱加前缀
一开始我给生成出来的 skill 和 agent 加了项目名前缀,比如:
text
claude-react-nnnnzs-cn-glb-model-inspector
看起来很稳,不会冲突。
但后来想想,这东西本来就是项目目录级别的,没必要这么长。
现在就保持和 Claude Code 源名字一致:
text
.claude/skills/glb-model-inspector
↓
.agents/skills/glb-model-inspector
.claude/agents/feature-planner.md
↓
.codex/agents/feature-planner.toml
这样看起来舒服很多。
真正踩坑的是 Windows 权限
我删掉生成目录以后,重新启动 Codex,发现 Hook 没有正常重新生成。
一开始我以为是 Hook 没触发。
后来手动跑脚本才发现,真凶是权限。
在当前 Codex 沙箱里,.codex 目录下面有额外的 ACL 限制,普通 hook 执行时不一定能新建 .codex/agents,甚至写 .codex/agents/*.toml 也可能失败。
但是 .agents/skills 能正常写。
所以脚本最后做了两个处理:
.codex/agents/.gitkeep提交进仓库,保证目录一直存在- agent 生成失败时只打 warning,不让整个 hook 失败
也就是说,skills 一定尽量恢复,agents 如果当前环境没权限写,就跳过。
有完整权限的时候,agents 还是会生成到官方路径:
text
.codex/agents/doc-sync-specialist.toml
.codex/agents/feature-planner.toml
AGENTS.md 也别写太多
我一开始把 AGENTS.md 写得很详细,后来发现和 .codex/README.md 重复了。
其实给大模型看的东西不应该太啰嗦。
最后我只保留了几句:
md
# Codex Instructions
Use @CLAUDE.md as the source of truth for this project.
Claude Code assets in `.claude/` are mirrored locally for Codex by the project hook. Do not edit generated bridge files; update the `.claude/` source instead.
这就够了。
详细的目录解释、Hook 说明,放 .codex/README.md 给人看。
快速配置指南
如果你想在自己的项目里用这套方案,按下面步骤来:
1. 创建目录结构
bash
mkdir -p .codex/hooks
mkdir -p .agents
2. 创建 .codex/config.toml
toml
sandbox_mode = "danger-full-access"
[features]
hooks = true
3. 创建 .codex/hooks.json
json
{
"description": "Sync Claude Code project skills and agents into local Codex bridge output.",
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "node .codex/hooks/sync-claude-to-codex.mjs",
"timeout": 30
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "node .codex/hooks/sync-claude-to-codex.mjs",
"timeout": 30
}
]
}
]
}
}
4. 创建 AGENTS.md
markdown
# Codex Instructions
Use @CLAUDE.md as the source of truth for this project.
Claude Code assets in `.claude/` are mirrored locally for Codex by the project hook.
Do not edit generated bridge files; update the `.claude/` source instead.
5. 更新 .gitignore
gitignore
# Codex bridge 生成产物
/.agents/skills/
/.agents/CLAUDE_BRIDGE.md
/.agents/claude-sync-manifest.json
/.codex/agents/*.toml
6. 添加 npm scripts (可选)
在 package.json 中添加:
json
{
"scripts": {
"codex:sync-claude": "node .codex/hooks/sync-claude-to-codex.mjs"
}
}
这样可以手动触发同步:pnpm codex:sync-claude
同步产物说明
| 文件 | 说明 |
|---|---|
.agents/skills/*/SKILL.md |
同步后的技能定义,包含生成标记 |
.codex/agents/*.toml |
转换后的 Agent 定义 (TOML 格式) |
.agents/CLAUDE_BRIDGE.md |
人类可读的桥接索引 |
.agents/claude-sync-manifest.json |
同步清单,用于清理过期产物 |
最后
这套方案现在看起来比较舒服:
- Claude Code 继续用自己的
.claude - Codex 用 hook 自动同步
- 生成物不进 git
- Win11 和 macOS 都不依赖软链接
- 项目规范只有一份源头
说实话,这事本身不复杂,复杂的是两个工具都在快速变,目录规范、feature flag、hook 事件名这些都可能变。
所以我现在的态度是:源头尽量少,生成物尽量本地化,能不软链就不软链。
少一点玄学,多一点可删了重来的东西。