记一次 Codex 和 Claude Code 共用项目规范

2026年05月24日110 次阅读0 人喜欢
Claude CodeClaude Code CLIAI踩坑记录MCP
所属合集

项目里我主要还是用 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;
});

脚本做了什么

  1. Skills 同步:遍历 .claude/skills/ 下每个目录,读取 SKILL.md 的 frontmatter,整个目录复制到 .agents/skills/<slugified-name>/,重写 SKILL.md 添加生成标记
  2. Agents 同步:遍历 .claude/agents/*.md,转换为 Codex 的 TOML 格式,写入 .codex/agents/<name>.toml
  3. 清理机制:维护 claude-sync-manifest.json,删除已不存在的旧目标文件
  4. 遗留处理:清理带 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 能正常写。

所以脚本最后做了两个处理:

  1. .codex/agents/.gitkeep 提交进仓库,保证目录一直存在
  2. 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 事件名这些都可能变。

所以我现在的态度是:源头尽量少,生成物尽量本地化,能不软链就不软链。

少一点玄学,多一点可删了重来的东西。

加载评论中...