一個同步Skill的Skill,讓Codex、CC的Skill保持同步

作者:HelloRanceLee
日期:2026年1月23日 上午4:01
來源:WeChat 原文

整理版優先睇

速讀 5 個重點 高亮

自製同步 Skill,一次搞掂 CodexClaude Code 嘅 Skills 不一致問題

整理版摘要

呢篇文章出自 Rance Lee(公眾號 HelloRanceLee),佢係一個同時用 CodexClaude Code 嘅 AI 編程用戶。佢發現雖然 Skills 好有用,但兩個工具各自要將 Skill 放喺唔同嘅 folder,每次更新都要手動複製,好鬼麻煩。為咗解決呢個問題,佢就寫咗一個專門用嚟同步兩邊 Skill 嘅自動化工具。

成個方案係一個完整嘅 Skill package,包含一個 SKILL.md(定義咗同步工作流程同規則)同一個 Python script(sync_skills.py),用嚟比對兩個目錄嘅差異,然後根據修改時間決定邊一邊係最新,並喺用戶確認後執行同步。佢仲考慮埋衝突情況——如果修改時間一樣但內容唔同,就暫停等用戶決定。

結論係:只要你跟住佢嘅步驟創建 Skill 同修改路徑,就可以一條指令自動同步 CodexClaude Code 嘅 Skills,慳返好多時間同避免唔一致嘅問題。呢個做法展示咗點樣用 Skill 本身去管理其他 Skill,係一種高效嘅 meta 應用。

  • 透過自製嘅同步 Skill,可以一次過解決 CodexClaude Code Skills 目錄唔一致嘅問題,唔使再手動複製。
  • 核心係一個 Python script(sync_skills.py),比對兩個 folder 嘅 skill 目錄,用修改時間判斷邊一邊最新,並提供差異報告。
  • 同步前預設只報告差異,用戶確認先至執行寫入,減少意外覆蓋。
  • 遇到衝突(同一時間但內容唔同)會暫停,等用戶揀保留 CodexClaude 嘅版本。
  • 呢個 Skill 本身都可以用嚟同步自己,實現「同步 Skill 的 Skill」,係一種實用嘅 meta 設計。
值得記低
Skill

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 同時用 CodexClaude 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 入面嘅 CodexClaude 目錄路徑,改成你電腦嘅真實地址。如果格式有問題,可以叫 AI 幫手改。

SKILL.md 內容 markdown
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 主要邏輯 python
#!/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. 1 確保你已經創建咗對應嘅 folder 同放好 SKILL.md 同 sync_skills.py,並且改好入面嘅路徑。
  2. 2 喺 terminal 執行 `python3 sync_skills.py` 睇下差異報告,確認 CodexClaude 嘅 Skills 有咩唔同。
  3. 3 如果冇問題,就加上 `--apply` 參數執行同步。如果遇到衝突(同一時間但內容唔同),腳本會暫停,你要揀保留 CodexClaude 嘅版本。
  4. 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,用 Tab 揀中然後直接用,或者揀中之後輸入你嘅需求再㩒 Enter。
PixPin_2026-01-23_11-22-37
PixPin_2026-01-23_11-22-37
PixPin_2026-01-23_11-22-55
PixPin_2026-01-23_11-22-55

咁問題嚟啦,如果你冇Skill呢?

唔使慌,你可以創建或者直接用其他人整好咗嘅Skill。

創建Skill

Codex入面自帶咗一個創建Skill嘅Skill,你只要揀中呢個 Skill 然後將要求話畀佢知,AI就會自動幫你創建Skill㗎喇。
PixPin_2026-01-23_11-23-29
PixPin_2026-01-23_11-23-29

用其他人嘅Skill

打開AI編程工具(Codex、Claude Code)嘅文件夾,以Codex為例,地址係根目錄嘅.codex/skills
PixPin_2026-01-23_11-24-12
PixPin_2026-01-23_11-24-12

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

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

同步Skill

唔多講,呢度就介紹一下我嘅Skill,你只需要跟住我嘅樣整文件、複製代碼,然後將Skill裏面提到嘅地址改成你嘅就可以直接用㗎喇。如果有複製入去有啲格式問題,你都可以叫你嘅AI喺我嘅基礎上去改。
PixPin_2026-01-23_11-25-07
PixPin_2026-01-23_11-25-07

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,用 Tab 選中然後直接使用,或者選中後輸入你的需求再回車。
PixPin_2026-01-23_11-22-37
PixPin_2026-01-23_11-22-37
PixPin_2026-01-23_11-22-55
PixPin_2026-01-23_11-22-55

那麼問題來了,如果你沒有Skill呢?

不用慌,你可以創建或者直接使用別人做好的Skill。

創建Skill

在Codex中自帶了一個創建Skill的Skill,你只要選中這個 Skill 然後把要求告訴它,AI就會自動幫你創建Skill了。
PixPin_2026-01-23_11-23-29
PixPin_2026-01-23_11-23-29

使用別人的Skill

打開AI編程工具(Codex、Claude Code)的文件夾,以Codex為例,地址是根目錄的.codex/skills
PixPin_2026-01-23_11-24-12
PixPin_2026-01-23_11-24-12

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

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

同步Skill

不多說,這裏就介紹一下我的Skill,你只需要按照我的樣子創建文件、複製代碼,然後把Skill裏面提到的地址修改成你的就可以直接使用了。如果有複製進去有些格式問題,你也可以讓你的AI在我的基礎上修改。
PixPin_2026-01-23_11-25-07
PixPin_2026-01-23_11-25-07

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())