一個同步Skill的Skill,讓Codex、CC的Skill保持同步
整理版優先睇
自製同步 Skill,一次搞掂 Codex 同 Claude Code 嘅 Skills 不一致問題
呢篇文章出自 Rance Lee(公眾號 HelloRanceLee),佢係一個同時用 Codex 同 Claude Code 嘅 AI 編程用戶。佢發現雖然 Skills 好有用,但兩個工具各自要將 Skill 放喺唔同嘅 folder,每次更新都要手動複製,好鬼麻煩。為咗解決呢個問題,佢就寫咗一個專門用嚟同步兩邊 Skill 嘅自動化工具。
成個方案係一個完整嘅 Skill package,包含一個 SKILL.md(定義咗同步工作流程同規則)同一個 Python script(sync_skills.py),用嚟比對兩個目錄嘅差異,然後根據修改時間決定邊一邊係最新,並喺用戶確認後執行同步。佢仲考慮埋衝突情況——如果修改時間一樣但內容唔同,就暫停等用戶決定。
結論係:只要你跟住佢嘅步驟創建 Skill 同修改路徑,就可以一條指令自動同步 Codex 同 Claude Code 嘅 Skills,慳返好多時間同避免唔一致嘅問題。呢個做法展示咗點樣用 Skill 本身去管理其他 Skill,係一種高效嘅 meta 應用。
- 透過自製嘅同步 Skill,可以一次過解決 Codex 同 Claude Code Skills 目錄唔一致嘅問題,唔使再手動複製。
- 核心係一個 Python script(sync_skills.py),比對兩個 folder 嘅 skill 目錄,用修改時間判斷邊一邊最新,並提供差異報告。
- 同步前預設只報告差異,用戶確認先至執行寫入,減少意外覆蓋。
- 遇到衝突(同一時間但內容唔同)會暫停,等用戶揀保留 Codex 定 Claude 嘅版本。
- 呢個 Skill 本身都可以用嚟同步自己,實現「同步 Skill 的 Skill」,係一種實用嘅 meta 設計。
codex-claude-skill-sync
SKILL.md 定義同步規則,包括預設目錄、只處理有 SKILL.md 嘅頂層目錄、使用者確認後執行。配合 sync_skills.py 使用。需修改入面嘅 Codex 同 Claude 目錄路徑。
sync_skills.py
Python script 執行同步邏輯,支援 --apply 執行同步、--prefer 指定衝突時偏愛一邊。需修改 DEFAULT_CODEX 同 DEFAULT_CLAUDE 路徑。
點解要整呢個同步 Skill?
Rance Lee 同時用 Codex 同 Claude Code 嚟寫 Code,佢發現兩個工具嘅 Skill 要分別放喺唔同嘅 folder,每次加咗新 Skill 或者更新咗內容,就要手動抄一次,好容易錯又曬時間。
所以佢諗咗一個方法:做一個可以自動同步嘅 Skill,唔使再用腦記住邊一邊新咗邊一邊舊咗,直接一條指令搞掂。呢個做法仲可以用嚟管理其他 Skill 嘅版本一致性。
同步 Skill 嘅本質係一個 meta-skill,用 Skill 本身去管理其他 Skill 嘅同步
Skill 係咩?點樣用?
Skill 係可重用嘅模塊,將工具、API、腳本同提示封裝成標準接口,畀模型按需要調用。簡單講,你喺 AI 編程工具入面打 /skill 就會見到所有可用嘅 Skill,揀中之後可以直接用或者再加具體要求。
如果你冇 Skill,可以自己創建(Codex 內置咗「創建 Skill 的 Skill」),或者去網上揾人分享嘅 Skill,放喺 .codex/skills 或者對應目錄就得。不過要留意,Codex 唔支援熱更新,加完要重啟先見到。
同步 Skill 嘅具體做法
跟住以下步驟就可以整好呢個同步 Skill:首先喺你嘅 Codex Skills 目錄下創建一個 folder(例如 codex-claude-skill-sync),入面放兩個檔案:SKILL.md 同 sync_skills.py。
SKILL.md 定義咗同步嘅流程同規則,包括預設目錄、比對邏輯、同埋衝突處理。sync_skills.py 係實際執行同步嘅腳本,佢會檢查兩個目錄嘅 skill 係咪一致,然後產生差異報告。
記住要修改 SKILL.md 同 sync_skills.py 入面嘅 Codex 同 Claude 目錄路徑,改成你電腦嘅真實地址。如果格式有問題,可以叫 AI 幫手改。
name: codex-claude-skill-sync
description: 同步Codex與Claude技能
---
# Codex/Claude Skill Sync
## 概述
用於檢查並同步 Codex 與 Claude 的技能目錄,保持兩邊一致。默認只報告差異,獲得用戶確認後再執行同步。
## 工作流
1. 運行差異報告(不修改):
`python3 scripts/sync_skills.py`
2. 用中文向用戶彙報差異並等待明確同意後再執行。
3. 同意後執行同步:
`python3 scripts/sync_skills.py --apply`
4. 遇到衝突(同一修改時間但內容不同)時暫停,詢問用戶選擇保留哪一側。
## 規則
- 默認目錄:
- Codex: `這裏寫你的電腦的地址`
- Claude: `這裏寫你的電腦的地址`
- 僅處理頂層且包含 `SKILL.md` 的目錄,跳過隱藏目錄與 `.system`
- 以目錄內最新修改時間判斷哪一側更新
- 同步時刪除目標技能目錄後再整體拷貝來源目錄
## 參數
- `--apply` 執行同步(默認只報告)
- `--codex <path>` 覆蓋 Codex 目錄
- `--claude <path>` 覆蓋 Claude 目錄
- `--prefer codex|claude` 當修改時間相同但內容不同,用指定一側覆蓋(需用戶明確授權)
#!/usr/bin/env python3
"""Compare and sync skill folders between Codex and Claude.
Default behavior is report-only. Use --apply to perform sync."""
from __future__ import annotations
import argparse
import hashlib
import os
from datetime import datetime
from pathlib import Path
import shutil
import sys
DEFAULT_CODEX = Path("這裏寫你的電腦的地址")
DEFAULT_CLAUDE = Path("這裏寫你的電腦的地址")
IGNORE_DIR_NAMES = {".git", ".idea", ".vscode", "__pycache__", ".pytest_cache", ".mypy_cache"}
IGNORE_FILE_NAMES = {".DS_Store"}
TIME_EPSILON = 1.0
... # 完整腳本請參考原文
def main() -> int:
args = parse_args()
try:
codex_skills, ignored_codex = list_skill_dirs(args.codex)
claude_skills, ignored_claude = list_skill_dirs(args.claude)
except (FileNotFoundError, NotADirectoryError) as exc:
print(str(exc), file=sys.stderr)
return 2
actions, identical, conflicts = build_plan(
codex_skills,
claude_skills,
args.codex,
args.claude,
args.prefer,
)
print_report(
actions, identical, conflicts,
args.codex, args.claude, args.apply,
ignored_codex, ignored_claude,
)
if args.apply and actions:
apply_actions(actions)
print("\nSync complete.")
elif args.apply and not actions:
print("\nNo changes to apply.")
if conflicts and not args.prefer:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
使用流程同注意事項
- 1 確保你已經創建咗對應嘅 folder 同放好 SKILL.md 同 sync_skills.py,並且改好入面嘅路徑。
- 2 喺 terminal 執行 `python3 sync_skills.py` 睇下差異報告,確認 Codex 同 Claude 嘅 Skills 有咩唔同。
- 3 如果冇問題,就加上 `--apply` 參數執行同步。如果遇到衝突(同一時間但內容唔同),腳本會暫停,你要揀保留 Codex 定 Claude 嘅版本。
- 4 同步完之後重啟 Codex / Claude Code 確保新 Skill 生效(因為 Codex 唔支援熱更新)。
如果你慣用特定一邊(例如永遠以 Codex 為準),可以用 `--prefer codex` 參數,咁就算衝突都會自動用 Codex 覆蓋 Claude,唔使逐次問。
同步完之後最好用 /skill 檢查一次,確保冇遺漏
歡迎關注個人公眾號「HelloRanceLee」同博客:https://blog.discoverlabs.ac.cn/
前言
近期AI編程吹起咗一波Skill風暴,各種Skill極大咁提升咗AI編程效率。
Skill係指可以重用嘅「能力模塊」,將工具/API/腳本同提示封裝成標準接口,等模型按需要調用嚟完成特定任務。佢強調清晰嘅輸入輸出、依賴同版本管理、可測試同可更新,用嚟將通用模型變成面向業務嘅專業助手。
但係好似我咁同時用多個編程工具嘅用戶會發現一個好大嘅問題,就係Codex、Claude Code嘅Skill要放喺各自嘅文件夾,每次同步都好麻煩。不過唔使驚,我哋可以做一個喺唔同程式入面同步Skill嘅Skill!
新手篇 — 點樣用Skill


咁問題嚟啦,如果你冇Skill呢?
唔使慌,你可以創建或者直接用其他人整好咗嘅Skill。
創建Skill

用其他人嘅Skill

如果你冇呢個文件夾,可以自己創建一個,然後將下載返嚟嘅其他人嘅Skill複製到呢個文件夾入面就可以用㗎喇。

不過要注意,Codex仲唔支援熱更新,所以安裝新Skill之後可能需要你退出再入過,否則可能顯示唔到。
同步Skill

SKILL.md 內容如下:
name: codex-claude-skill-sync
description: 同步Codex與Claude技能
---
# Codex/Claude Skill Sync
## 概述
用於檢查並同步 Codex 與 Claude 的技能目錄,保持兩邊一致。默認只報告差異,獲得用戶確認後再執行同步。
## 工作流
1. 運行差異報告(不修改):
`python3 scripts/sync_skills.py`
2. 用中文向用戶彙報差異並等待明確同意後再執行。
3. 同意後執行同步:
`python3 scripts/sync_skills.py --apply`
4. 遇到衝突(同一修改時間但內容不同)時暫停,詢問用戶選擇保留哪一側。
## 規則
- 默認目錄:
- Codex: `這裏寫你的電腦的地址`
- Claude: `這裏寫你的電腦的地址`
- 僅處理頂層且包含 `SKILL.md` 的目錄,跳過隱藏目錄與 `.system`
- 以目錄內最新修改時間判斷哪一側更新
- 同步時刪除目標技能目錄後再整體拷貝來源目錄
## 參數
- `--apply` 執行同步(默認只報告)
- `--codex <path>` 覆蓋 Codex 目錄
- `--claude <path>` 覆蓋 Claude 目錄
- `--prefer codex|claude` 當修改時間相同但內容不同,用指定一側覆蓋(需用戶明確授權)
sync_skills.py 內容如下:
#!/usr/bin/env python3
"""
Compare and sync skill folders between Codex and Claude.
Default behavior is report-only. Use --apply to perform sync.
"""
from __future__ import annotations
import argparse
import hashlib
import os
from datetime import datetime
from pathlib import Path
import shutil
import sys
DEFAULT_CODEX = Path("這裏寫你的電腦的地址")
DEFAULT_CLAUDE = Path("這裏寫你的電腦的地址")
IGNORE_DIR_NAMES = {".git", ".idea", ".vscode", "__pycache__", ".pytest_cache", ".mypy_cache"}
IGNORE_FILE_NAMES = {".DS_Store"}
TIME_EPSILON = 1.0
def format_time(timestamp: float) -> str:
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
def list_skill_dirs(root: Path) -> tuple[dict[str, Path], list[str]]:
if not root.exists():
raise FileNotFoundError(f"Root path does not exist: {root}")
if not root.is_dir():
raise NotADirectoryError(f"Root path is not a directory: {root}")
skills: dict[str, Path] = {}
ignored: list[str] = []
for entry in sorted(root.iterdir(), key=lambda p: p.name):
if not entry.is_dir():
continue
if entry.name.startswith("."):
ignored.append(entry.name)
continue
if not (entry / "SKILL.md").is_file():
continue
skills[entry.name] = entry
return skills, ignored
def dir_state(path: Path) -> tuple[str, float, int]:
hasher = hashlib.sha256()
latest_mtime = path.stat().st_mtime
file_count = 0
for root, dirs, files in os.walk(path):
dirs[:] = [d for d in dirs if d not in IGNORE_DIR_NAMES]
dirs.sort()
files = sorted(f for f in files if f not in IGNORE_FILE_NAMES)
rel_dir = os.path.relpath(root, path)
if rel_dir == ".":
rel_dir = ""
hasher.update(f"D|{rel_dir}\n".encode())
try:
latest_mtime = max(latest_mtime, os.stat(root).st_mtime)
except FileNotFoundError:
continue
for name in files:
file_path = Path(root) / name
rel_path = os.path.relpath(file_path, path)
if file_path.is_symlink():
try:
target = os.readlink(file_path)
except OSError:
target = ""
hasher.update(f"L|{rel_path}\n{target}\n".encode())
try:
latest_mtime = max(latest_mtime, file_path.lstat().st_mtime)
except FileNotFoundError:
pass
continue
if not file_path.is_file():
continue
stat = file_path.stat()
latest_mtime = max(latest_mtime, stat.st_mtime)
file_count += 1
hasher.update(f"F|{rel_path}\n{stat.st_size}\n".encode())
with open(file_path, "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
hasher.update(chunk)
return hasher.hexdigest(), latest_mtime, file_count
def build_plan(
codex_skills: dict[str, Path],
claude_skills: dict[str, Path],
codex_root: Path,
claude_root: Path,
prefer: str | None,
) -> tuple[list[dict], list[str], list[dict]]:
actions: list[dict] = []
identical: list[str] = []
conflicts: list[dict] = []
all_names = sorted(set(codex_skills) | set(claude_skills))
for name in all_names:
codex_path = codex_skills.get(name)
claude_path = claude_skills.get(name)
if codex_path and not claude_path:
actions.append(
{
"name": name,
"src": codex_path,
"dst": claude_root / name,
"reason": "only in codex",
"direction": "codex -> claude",
}
)
continue
if claude_path and not codex_path:
actions.append(
{
"name": name,
"src": claude_path,
"dst": codex_root / name,
"reason": "only in claude",
"direction": "claude -> codex",
}
)
continue
if not codex_path or not claude_path:
continue
codex_hash, codex_mtime, _ = dir_state(codex_path)
claude_hash, claude_mtime, _ = dir_state(claude_path)
if codex_hash == claude_hash:
identical.append(name)
continue
time_delta = codex_mtime - claude_mtime
if abs(time_delta) <= TIME_EPSILON:
if prefer == "codex":
actions.append(
{
"name": name,
"src": codex_path,
"dst": claude_path,
"reason": "same mtime, prefer codex",
"direction": "codex -> claude",
"codex_mtime": codex_mtime,
"claude_mtime": claude_mtime,
}
)
elif prefer == "claude":
actions.append(
{
"name": name,
"src": claude_path,
"dst": codex_path,
"reason": "same mtime, prefer claude",
"direction": "claude -> codex",
"codex_mtime": codex_mtime,
"claude_mtime": claude_mtime,
}
)
else:
conflicts.append(
{
"name": name,
"codex_mtime": codex_mtime,
"claude_mtime": claude_mtime,
}
)
continue
if time_delta > 0:
actions.append(
{
"name": name,
"src": codex_path,
"dst": claude_path,
"reason": "codex newer",
"direction": "codex -> claude",
"codex_mtime": codex_mtime,
"claude_mtime": claude_mtime,
}
)
else:
actions.append(
{
"name": name,
"src": claude_path,
"dst": codex_path,
"reason": "claude newer",
"direction": "claude -> codex",
"codex_mtime": codex_mtime,
"claude_mtime": claude_mtime,
}
)
return actions, identical, conflicts
def print_report(
actions: list[dict],
identical: list[str],
conflicts: list[dict],
codex_root: Path,
claude_root: Path,
apply: bool,
ignored_codex: list[str],
ignored_claude: list[str],
) -> None:
print("Skill sync report")
print(f"Codex: {codex_root}")
print(f"Claude: {claude_root}")
if ignored_codex:
print(f"Ignored in Codex: {', '.join(sorted(ignored_codex))}")
if ignored_claude:
print(f"Ignored in Claude: {', '.join(sorted(ignored_claude))}")
print("\nPlanned sync actions:")
if not actions:
print("- none")
else:
for item in actions:
codex_mtime = item.get("codex_mtime")
claude_mtime = item.get("claude_mtime")
details = []
if codex_mtime is not None:
details.append(f"codex mtime: {format_time(codex_mtime)}")
if claude_mtime is not None:
details.append(f"claude mtime: {format_time(claude_mtime)}")
detail_text = f" ({', '.join(details)})" if details else ""
print(f"- {item['name']}: {item['direction']} [{item['reason']}]" + detail_text)
print("\nConflicts:")
if not conflicts:
print("- none")
else:
for item in conflicts:
print(
f"- {item['name']}: same mtime but different content "
f"(codex {format_time(item['codex_mtime'])}, claude {format_time(item['claude_mtime'])})"
)
print(f"\nUp-to-date skills: {len(identical)}")
if not apply:
print("\nDry run only. Re-run with --apply to sync.")
def apply_actions(actions: list[dict]) -> None:
for item in actions:
src = Path(item["src"])
dst = Path(item["dst"])
if dst.exists():
if dst.is_dir():
shutil.rmtree(dst)
else:
dst.unlink()
shutil.copytree(src, dst, symlinks=True)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Sync Codex and Claude skill folders")
parser.add_argument("--codex", type=Path, default=DEFAULT_CODEX, help="Codex skill root")
parser.add_argument("--claude", type=Path, default=DEFAULT_CLAUDE, help="Claude skill root")
parser.add_argument("--apply", action="store_true", help="Apply sync actions")
parser.add_argument(
"--prefer",
choices=["codex", "claude"],
help="Break ties when mtimes are equal",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
codex_skills, ignored_codex = list_skill_dirs(args.codex)
claude_skills, ignored_claude = list_skill_dirs(args.claude)
except (FileNotFoundError, NotADirectoryError) as exc:
print(str(exc), file=sys.stderr)
return 2
actions, identical, conflicts = build_plan(
codex_skills,
claude_skills,
args.codex,
args.claude,
args.prefer,
)
print_report(
actions,
identical,
conflicts,
args.codex,
args.claude,
args.apply,
ignored_codex,
ignored_claude,
)
if args.apply and actions:
apply_actions(actions)
print("\nSync complete.")
elif args.apply and not actions:
print("\nNo changes to apply.")
if conflicts and not args.prefer:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
歡迎關注個人公眾號“HelloRanceLee”及博客:https://blog.discoverlabs.ac.cn/
前言
近期AI編程颳起了一波Skill風暴,各種Skill極大地提高了AI編程效率。
Skill指的是可複用的“能力模塊”,把工具/API/腳本與提示封裝成標準接口,讓模型按需調用完成特定任務。它強調清晰輸入輸出、依賴與版本管理、可測試可更新,用於把通用模型變成面向業務的專業助手。
但是像我這樣同時使用多個編程工具的用戶會發現一個很大的問題就是Codex、Claude Code的Skill要放在各自的文件夾,每次同步都十分麻煩。但是不慌,我們可以做一個在不同程序中同步Skill的Skill!
新手向-如何使用Skill


那麼問題來了,如果你沒有Skill呢?
不用慌,你可以創建或者直接使用別人做好的Skill。
創建Skill

使用別人的Skill

如果你沒有這個文件夾,可以自己創建一個,然後把下載的別人的Skill複製到這個文件夾中就可以使用了。

不過需要注意,Codex還不支持熱更新,因此安裝新Skill後可能需要你退出重進,不然可能無法顯示。
同步Skill

SKILL.md 內容如下:
name: codex-claude-skill-sync
description: 同步Codex與Claude技能
---
# Codex/Claude Skill Sync
## 概述
用於檢查並同步 Codex 與 Claude 的技能目錄,保持兩邊一致。默認只報告差異,獲得用戶確認後再執行同步。
## 工作流
1. 運行差異報告(不修改):
`python3 scripts/sync_skills.py`
2. 用中文向用戶彙報差異並等待明確同意後再執行。
3. 同意後執行同步:
`python3 scripts/sync_skills.py --apply`
4. 遇到衝突(同一修改時間但內容不同)時暫停,詢問用戶選擇保留哪一側。
## 規則
- 默認目錄:
- Codex: `這裏寫你的電腦的地址`
- Claude: `這裏寫你的電腦的地址`
- 僅處理頂層且包含 `SKILL.md` 的目錄,跳過隱藏目錄與 `.system`
- 以目錄內最新修改時間判斷哪一側更新
- 同步時刪除目標技能目錄後再整體拷貝來源目錄
## 參數
- `--apply` 執行同步(默認只報告)
- `--codex <path>` 覆蓋 Codex 目錄
- `--claude <path>` 覆蓋 Claude 目錄
- `--prefer codex|claude` 當修改時間相同但內容不同,用指定一側覆蓋(需用戶明確授權)
sync_skills.py內容如下:
#!/usr/bin/env python3
"""
Compare and sync skill folders between Codex and Claude.
Default behavior is report-only. Use --apply to perform sync.
"""
from __future__ import annotations
import argparse
import hashlib
import os
from datetime import datetime
from pathlib import Path
import shutil
import sys
DEFAULT_CODEX = Path("這裏寫你的電腦的地址")
DEFAULT_CLAUDE = Path("這裏寫你的電腦的地址")
IGNORE_DIR_NAMES = {".git", ".idea", ".vscode", "__pycache__", ".pytest_cache", ".mypy_cache"}
IGNORE_FILE_NAMES = {".DS_Store"}
TIME_EPSILON = 1.0
def format_time(timestamp: float) -> str:
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M:%S")
def list_skill_dirs(root: Path) -> tuple[dict[str, Path], list[str]]:
if not root.exists():
raise FileNotFoundError(f"Root path does not exist: {root}")
if not root.is_dir():
raise NotADirectoryError(f"Root path is not a directory: {root}")
skills: dict[str, Path] = {}
ignored: list[str] = []
for entry in sorted(root.iterdir(), key=lambda p: p.name):
if not entry.is_dir():
continue
if entry.name.startswith("."):
ignored.append(entry.name)
continue
if not (entry / "SKILL.md").is_file():
continue
skills[entry.name] = entry
return skills, ignored
def dir_state(path: Path) -> tuple[str, float, int]:
hasher = hashlib.sha256()
latest_mtime = path.stat().st_mtime
file_count = 0
for root, dirs, files in os.walk(path):
dirs[:] = [d for d in dirs if d not in IGNORE_DIR_NAMES]
dirs.sort()
files = sorted(f for f in files if f not in IGNORE_FILE_NAMES)
rel_dir = os.path.relpath(root, path)
if rel_dir == ".":
rel_dir = ""
hasher.update(f"D|{rel_dir}\n".encode())
try:
latest_mtime = max(latest_mtime, os.stat(root).st_mtime)
except FileNotFoundError:
continue
for name in files:
file_path = Path(root) / name
rel_path = os.path.relpath(file_path, path)
if file_path.is_symlink():
try:
target = os.readlink(file_path)
except OSError:
target = ""
hasher.update(f"L|{rel_path}\n{target}\n".encode())
try:
latest_mtime = max(latest_mtime, file_path.lstat().st_mtime)
except FileNotFoundError:
pass
continue
if not file_path.is_file():
continue
stat = file_path.stat()
latest_mtime = max(latest_mtime, stat.st_mtime)
file_count += 1
hasher.update(f"F|{rel_path}\n{stat.st_size}\n".encode())
with open(file_path, "rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
hasher.update(chunk)
return hasher.hexdigest(), latest_mtime, file_count
def build_plan(
codex_skills: dict[str, Path],
claude_skills: dict[str, Path],
codex_root: Path,
claude_root: Path,
prefer: str | None,
) -> tuple[list[dict], list[str], list[dict]]:
actions: list[dict] = []
identical: list[str] = []
conflicts: list[dict] = []
all_names = sorted(set(codex_skills) | set(claude_skills))
for name in all_names:
codex_path = codex_skills.get(name)
claude_path = claude_skills.get(name)
if codex_path and not claude_path:
actions.append(
{
"name": name,
"src": codex_path,
"dst": claude_root / name,
"reason": "only in codex",
"direction": "codex -> claude",
}
)
continue
if claude_path and not codex_path:
actions.append(
{
"name": name,
"src": claude_path,
"dst": codex_root / name,
"reason": "only in claude",
"direction": "claude -> codex",
}
)
continue
if not codex_path or not claude_path:
continue
codex_hash, codex_mtime, _ = dir_state(codex_path)
claude_hash, claude_mtime, _ = dir_state(claude_path)
if codex_hash == claude_hash:
identical.append(name)
continue
time_delta = codex_mtime - claude_mtime
if abs(time_delta) <= TIME_EPSILON:
if prefer == "codex":
actions.append(
{
"name": name,
"src": codex_path,
"dst": claude_path,
"reason": "same mtime, prefer codex",
"direction": "codex -> claude",
"codex_mtime": codex_mtime,
"claude_mtime": claude_mtime,
}
)
elif prefer == "claude":
actions.append(
{
"name": name,
"src": claude_path,
"dst": codex_path,
"reason": "same mtime, prefer claude",
"direction": "claude -> codex",
"codex_mtime": codex_mtime,
"claude_mtime": claude_mtime,
}
)
else:
conflicts.append(
{
"name": name,
"codex_mtime": codex_mtime,
"claude_mtime": claude_mtime,
}
)
continue
if time_delta > 0:
actions.append(
{
"name": name,
"src": codex_path,
"dst": claude_path,
"reason": "codex newer",
"direction": "codex -> claude",
"codex_mtime": codex_mtime,
"claude_mtime": claude_mtime,
}
)
else:
actions.append(
{
"name": name,
"src": claude_path,
"dst": codex_path,
"reason": "claude newer",
"direction": "claude -> codex",
"codex_mtime": codex_mtime,
"claude_mtime": claude_mtime,
}
)
return actions, identical, conflicts
def print_report(
actions: list[dict],
identical: list[str],
conflicts: list[dict],
codex_root: Path,
claude_root: Path,
apply: bool,
ignored_codex: list[str],
ignored_claude: list[str],
) -> None:
print("Skill sync report")
print(f"Codex: {codex_root}")
print(f"Claude: {claude_root}")
if ignored_codex:
print(f"Ignored in Codex: {', '.join(sorted(ignored_codex))}")
if ignored_claude:
print(f"Ignored in Claude: {', '.join(sorted(ignored_claude))}")
print("\nPlanned sync actions:")
if not actions:
print("- none")
else:
for item in actions:
codex_mtime = item.get("codex_mtime")
claude_mtime = item.get("claude_mtime")
details = []
if codex_mtime is not None:
details.append(f"codex mtime: {format_time(codex_mtime)}")
if claude_mtime is not None:
details.append(f"claude mtime: {format_time(claude_mtime)}")
detail_text = f" ({', '.join(details)})" if details else ""
print(f"- {item['name']}: {item['direction']} [{item['reason']}]" + detail_text)
print("\nConflicts:")
if not conflicts:
print("- none")
else:
for item in conflicts:
print(
f"- {item['name']}: same mtime but different content "
f"(codex {format_time(item['codex_mtime'])}, claude {format_time(item['claude_mtime'])})"
)
print(f"\nUp-to-date skills: {len(identical)}")
if not apply:
print("\nDry run only. Re-run with --apply to sync.")
def apply_actions(actions: list[dict]) -> None:
for item in actions:
src = Path(item["src"])
dst = Path(item["dst"])
if dst.exists():
if dst.is_dir():
shutil.rmtree(dst)
else:
dst.unlink()
shutil.copytree(src, dst, symlinks=True)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Sync Codex and Claude skill folders")
parser.add_argument("--codex", type=Path, default=DEFAULT_CODEX, help="Codex skill root")
parser.add_argument("--claude", type=Path, default=DEFAULT_CLAUDE, help="Claude skill root")
parser.add_argument("--apply", action="store_true", help="Apply sync actions")
parser.add_argument(
"--prefer",
choices=["codex", "claude"],
help="Break ties when mtimes are equal",
)
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
codex_skills, ignored_codex = list_skill_dirs(args.codex)
claude_skills, ignored_claude = list_skill_dirs(args.claude)
except (FileNotFoundError, NotADirectoryError) as exc:
print(str(exc), file=sys.stderr)
return 2
actions, identical, conflicts = build_plan(
codex_skills,
claude_skills,
args.codex,
args.claude,
args.prefer,
)
print_report(
actions,
identical,
conflicts,
args.codex,
args.claude,
args.apply,
ignored_codex,
ignored_claude,
)
if args.apply and actions:
apply_actions(actions)
print("\nSync complete.")
elif args.apply and not actions:
print("\nNo changes to apply.")
if conflicts and not args.prefer:
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())