Skill 跨工具同步 Runbook:Codex / Claude Code
适用:把”一份 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 默认不跑,需先信任:
- 打开交互式 Codex(TUI),会弹
Hooks need review。 - 按
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 侧,排错用)
async = true不支持:Codex 报skipping async hook: async hooks are not supported yet然后跳过——所以步骤 6 的 Codex hook 不带 async(CC 那边支持,故步骤 5 带 async)。- 要先信任 hook(步骤 7):未信任不跑。另外
codex exec(非交互)根本不触发生命周期 hook,只有交互式 TUI 才会——所以 hook 没法用codex exec验证。 - 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.json的skillOverrides: {"<name>": "user-invocable-only"}(集中配置、不动 SKILL.md,适合共享/软链的 skill)。 - Codex:
agents/openai.yaml里policy: allow_implicit_invocation: false(默认 true)。
两边都没有”全局一刀切”开关,都是 per-skill。