posts/2026-06-23-skill-sync-setup.md

Skill 跨工具同步 Runbook:Codex / Claude Code

2026-06-23 4 min #ai-agent #tooling

适用:把”一份 skill 同时给 Codex 和 Claude Code 用、新建自动同步”这套配置,在任意 macOS/Linux 用户机器上从零装好。可由人或 agent(Codex / Claude Code)端到端执行。

给执行 agent 的须知(先读)

  • 全程用 $HOME,不要写死用户名。 本文不含任何特定用户的绝对路径。
  • 每步幂等:可重复执行,已装好的会跳过。
  • 步骤 5、6 改的是用户已有配置文件settings.json / config.toml):必须合并/追加 + 先备份绝不整文件覆盖。下面给的命令已内置幂等判断和备份。
  • 步骤 7 是交互式 TUI 操作,agent 做不了——要明确让用户手动完成。
  • 装完执行步骤 8 验证,把结果回报。

背景:这套干什么

一份 skill,Codex 和 Claude Code 共用:单一真源,改一次两边同步,新建自动归位。机制是——所有 skill 的真身只放 ~/.agents/skills,软链进两个工具各自的目录;回合结束时 Stop hook 自动把新建的收编进来。

关键事实:三个目录谁读谁(理解为什么这么配)

工具读取目录显式调用
Claude Code~/.claude/skills/<name>/(及项目 .claude/skills//<name>
Codex~/.codex/skills/<name>/(= $CODEX_HOME/skills$<name>
真源(中立)~/.agents/skills/<name>/——
  • 两个工具都不原生读 ~/.agents/skills,正好拿它当谁都不偏的真源。
  • Codex 里的 .agents/插件市场路径(~/.agents/plugins/marketplace.json),跟 skill 无关——别混。
  • 所以”软链进 Codex”和”软链进 CC”两条腿缺一不可

(注:以上 Codex 路径以本机二进制实测为准;官方文档曾写 .agents/skills,与实际不符。装之前 agent 应在目标机上确认 Codex 实际读 ~/.codex/skills,最稳是装完用步骤 8 端到端验证。)


安装步骤(按顺序执行)

步骤 0:先决条件

command -v bash >/dev/null && command -v python3 >/dev/null && echo "deps ok" || echo "缺 bash 或 python3"
# 目标机需已安装 Codex 和/或 Claude Code(只装一个也能用,对应那侧生效即可)

步骤 1:建真源目录

mkdir -p "$HOME/.agents/skills"

步骤 2:写 skill-sync 脚本并加可执行位

脚本内部全用 $HOME,对任何用户通用。直接执行下面这段(<<'SKILLSYNC' 用引号包裹,确保 $HOME$1 等原样写入文件、不在写入时展开):

mkdir -p "$HOME/.local/bin"
cat > "$HOME/.local/bin/skill-sync" <<'SKILLSYNC'
#!/usr/bin/env bash
# skill-sync <name> | --all
# 把 skill 归位到真源 ~/.agents/skills/<name>,并软链进 Codex(~/.codex/skills)
# 与 Claude Code(~/.claude/skills),让两个工具共用同一份、改一次两边同步。
#   skill-sync <name>  同步单个
#   skill-sync --all   扫描两边目录里所有“真实目录” + 真源里所有 skill,逐个同步
# 幂等:已链好则跳过;软链指错则修;目标是真实文件则拒绝覆盖、绝不误删。
# --all 对冲突容错(遇到拒绝覆盖只跳过并告警,不中断)。
set -uo pipefail

CANON="$HOME/.agents/skills"
TARGETS=("$HOME/.codex/skills" "$HOME/.claude/skills")

sync_one() {
  local name="$1"
  local canon="$CANON/$name"
  local t src link cur
  # 1) 真源没有:从工具目录里找一个“真实目录”搬过来
  if [[ ! -e "$canon" ]]; then
    for t in "${TARGETS[@]}"; do
      src="$t/$name"
      if [[ -d "$src" && ! -L "$src" ]]; then
        mkdir -p "$CANON"; mv "$src" "$canon"; echo "moved $src -> $canon"; break
      fi
    done
  fi
  if [[ ! -d "$canon" || -L "$canon" ]]; then
    echo "skip $name: 真源 $canon 不存在或不是真实目录" >&2; return 1
  fi
  # 2) 链进两边(已正确则跳过,指向错则修,遇到真实文件拒绝覆盖)
  for t in "${TARGETS[@]}"; do
    mkdir -p "$t"; link="$t/$name"
    if [[ -L "$link" ]]; then
      cur="$(readlink "$link")"
      [[ "$cur" == "$canon" || "$cur" == "../../.agents/skills/$name" ]] && continue
      echo "fix: $link (was -> $cur)"; rm "$link"
    elif [[ -e "$link" ]]; then
      echo "skip link $link: 已是真实文件/目录,拒绝覆盖" >&2; continue
    fi
    ln -s "$canon" "$link"; echo "linked $link -> $canon"
  done
  return 0
}

arg="${1:?usage: skill-sync <skill-name> | --all}"
if [[ "$arg" == "--all" ]]; then
  names="$( { ls -1 "$CANON" 2>/dev/null
              for t in "${TARGETS[@]}"; do
                [[ -d "$t" ]] || continue
                for p in "$t"/*; do [[ -d "$p" && ! -L "$p" ]] && basename "$p"; done
              done; } | grep -vE '^\.' | sort -u )"
  # progress -> stderr; reserve stdout for Stop-hook JSON (Codex/CC parse stdout as JSON)
  { for n in $names; do sync_one "$n" || true; done; } 1>&2
  # emit valid JSON only in hook context (stdout not a TTY); keep manual --all clean
  [[ -t 1 ]] || echo '{}'
else
  sync_one "$arg" && echo "done: $arg 已在真源并链入 Codex + Claude Code"
fi
SKILLSYNC
chmod +x "$HOME/.local/bin/skill-sync"
echo "installed: $HOME/.local/bin/skill-sync"

步骤 3:确认 ~/.local/bin 在 PATH(仅手动调用需要)

hook 用绝对路径,不依赖 PATH;但手动 skill-sync <name> 需要。

case ":$PATH:" in
  *":$HOME/.local/bin:"*) echo "PATH ok" ;;
  *) echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$HOME/.zshrc"
     echo "已加入 ~/.zshrc(非 zsh 用户改对应 rc 文件,然后重开终端)" ;;
esac

步骤 4:迁移现有 skill(把已有的收编进真源 + 双链)

"$HOME/.local/bin/skill-sync" --all
echo "migrate done"

步骤 5:配置 Claude Code 的 Stop hook(合并进 settings.json,不覆盖)

用 python3 安全合并、幂等(命令里的 $HOME 故意保留为字面量,由 CC 在 hook 运行时展开):

python3 - "$HOME/.claude/settings.json" <<'PY'
import json, os, sys
path = sys.argv[1]
os.makedirs(os.path.dirname(path), exist_ok=True)
try:
    data = json.load(open(path))
except (FileNotFoundError, ValueError):
    data = {}
cmd = "$HOME/.local/bin/skill-sync --all >/dev/null 2>&1 || true"
stop = data.setdefault("hooks", {}).setdefault("Stop", [])
exists = any(h.get("command") == cmd for g in stop for h in g.get("hooks", []))
if exists:
    print("CC Stop hook 已存在,跳过")
else:
    stop.append({"hooks": [{"type": "command", "command": cmd, "async": True}]})
    json.dump(data, open(path, "w"), indent=2, ensure_ascii=False)
    print("CC Stop hook 已写入", path)
PY

步骤 6:配置 Codex 的 Stop hook(备份后追加 config.toml)

幂等(已有则跳过)+ 先备份。这里 $HOME 用真实路径写入(Codex 的 hook 命令用字面绝对路径最稳):

CFG="$HOME/.codex/config.toml"
if [ -f "$CFG" ] && grep -q 'skill-sync --all' "$CFG"; then
  echo "Codex hook 已存在,跳过"
else
  [ -f "$CFG" ] && cp -p "$CFG" "$CFG.bak.skillsync-$(date +%s)" && echo "已备份 $CFG"
  mkdir -p "$HOME/.codex"
  cat >> "$CFG" <<EOF

[[hooks.Stop]]
[[hooks.Stop.hooks]]
type = "command"
command = "$HOME/.local/bin/skill-sync --all"
EOF
  echo "Codex hook 已追加到 $CFG"
fi

步骤 7:信任 Codex hook(人工,agent 做不了

Codex 的 hook 默认不跑,需先信任:

  1. 打开交互式 Codex(TUI),会弹 Hooks need review
  2. t 信任全部,或回车逐条 review 后信任。信任会持久化,之后不再问。

Claude Code 侧无此步——它读 settings.json 即生效(必要时重开 CC 或开一次 /hooks 菜单重载)。

步骤 8:验证

# 1) Codex 认不认这份配置(应看到 config.toml parse ok / 0 fail)
codex --strict-config doctor 2>&1 | grep -E "config.toml parse|fail" || true

# 2) 造个临时 skill,模拟 hook 调用,确认 stdout 是合法 JSON 且能收编
s=zz_runbook_test
mkdir -p "$HOME/.codex/skills/$s" && printf '%s\n' '---' "name: $s" 'description: t' '---' x > "$HOME/.codex/skills/$s/SKILL.md"
out=$("$HOME/.local/bin/skill-sync" --all 2>/dev/null)
echo "$out" | python3 -c "import sys,json;json.load(sys.stdin);print('stdout 合法 JSON ✓')"
[ -d "$HOME/.agents/skills/$s" ] && [ -L "$HOME/.codex/skills/$s" ] && [ -L "$HOME/.claude/skills/$s" ] && echo "收编成功 ✓"
rm -rf "$HOME/.agents/skills/$s" "$HOME/.codex/skills/$s" "$HOME/.claude/skills/$s"

# 3) 真·端到端(需人工):交互式 Codex 里建个 skill、结束一轮,
#    再查 ls -ld "$HOME/.agents/skills/<name>" "$HOME/.codex/skills/<name>"

补充:校验 config.toml 语法(~ 在 Python open() 里不展开,用 $HOME 或绝对路径):

python3 -c "import tomllib; tomllib.load(open('$HOME/.codex/config.toml','rb'));print('TOML ok')"

日常使用

  • 建完 skill 跑一次 skill-sync <name>;或什么都不做,交给 hook 在回合结束自动收编。
  • 两侧 hook 都在每个项目、每一轮回合结束skill-sync --all;没活干时幂等空跑,开销可忽略。
  • 跨工具最终一致:CC 那侧的 --all 也扫 ~/.codex/skills,所以哪怕某侧 hook 没生效,另一侧下次结束回合也会兜底收编。

踩过的三个坑(都在 Codex 侧,排错用)

  1. async = true 不支持:Codex 报 skipping async hook: async hooks are not supported yet 然后跳过——所以步骤 6 的 Codex hook 不带 async(CC 那边支持,故步骤 5 带 async)。
  2. 要先信任 hook(步骤 7):未信任不跑。另外 codex exec(非交互)根本不触发生命周期 hook,只有交互式 TUI 才会——所以 hook 没法用 codex exec 验证。
  3. Stop hook 的 stdout 必须是合法 JSON:Codex 把命令 stdout 当 JSON 解析,打了人话文本就报 invalid stop hook JSON output(副作用照常执行,只是被标记 failed)。skill-sync --all 已处理:进度走 stderr,stdout 只在非 TTY 吐 {}

附:让某 skill 只能手动调(相关但独立)

  • Claude Code:SKILL.md frontmatter disable-model-invocation: true,或 ~/.claude/settings.jsonskillOverrides: {"<name>": "user-invocable-only"}(集中配置、不动 SKILL.md,适合共享/软链的 skill)。
  • Codex:agents/openai.yamlpolicy: allow_implicit_invocation: false(默认 true)。

两边都没有”全局一刀切”开关,都是 per-skill。


// share: