[AI 工具] · · 22min read

我怎麼用 Claude Code 把 /sync 從 271 行 markdown 重寫成開源工具

把 ~/.claude/commands/sync.md 內嵌的 271 行 bash 抽出來變成獨立 sync.sh 並開源到 GitHub。記錄過程踩到的 6 個 macOS / bash / rsync 雷,以及與 Claude Code 協作的 ralph-loop 工作流。

章節目錄 · 12

# 我怎麼用 Claude Code 把 /sync 從 271 行 markdown 重寫成開源工具

TL;DR
把放在 ~/.claude/commands/sync.md 裡 271 行內嵌 bash 抽出來,變成獨立的 sync.sh + 薄 markdown wrapper,過程中跟 Claude Code 一起踩了 6 個 bash / rsync / 跨平台雷。最後開源到 github.com/yanchen184/claude-self-sync。這篇紀錄真實過程、犯過的錯,跟一個有用但不直觀的工作流:讓 Claude Code 開 ralph-loop 自動跑到完成

如果你跟我一樣,會把工具寫死在 Claude Code 的 slash command markdown 裡——很方便、很快、但有一天你會想開源它,或單純想讓它能在 terminal 直接跑。這篇就是那一天的故事。

目錄

  • 起點:為什麼要把腳本搬離 markdown

  • 踩坑 1:macOS 沒有 flock

  • 踩坑 2:openrsync 的 unlinkat 噪音

  • 踩坑 3:中文全形 把 bash 變數名吃掉

  • 踩坑 4:ls glob + set -o pipefail 自殺事件

  • 踩坑 5:rsync --delete 差點清掉今天的工作

  • 踩坑 6:dest repo 殘留 __pycache__ 讓 rsync 卡死

  • 跟 Claude Code 協作的工作流

  • 開源前的 checklist

  • 常見問題 FAQ

  • 延伸資源
  • 起點:為什麼要把腳本搬離 markdown

    我的 /sync slash command 一開始長這樣:

    # Sync Claude Config
    

    You are a sync assistant. Run the following bash:

    \\\bash
    LOCK_FILE=/tmp/claude-self-sync.lock
    flock -n 9 || exit 1
    ... (271 lines)
    \
    \\

    Report the result to the user.

    LLM 跑得起來,但問題堆積:

  • 無法在 terminal 直接跑。 想 cron / launchd 排程?沒辦法。

  • debug 痛苦。 bash 包在 markdown 裡,沒 syntax highlight、沒 lint、改錯 LLM 自己也修不好。

  • 不能開源。 markdown 形式只 Claude Code 用戶能用。

  • 修改成本高。 每次微調都要 LLM 重跑,不是純 shell bash -x 直接看。
  • 決定拆成 sync.sh(純 bash 獨立可跑)+ sync.md(薄包裝,只負責解 args 跟印結果)。

    預期 30 分鐘搞定,實際 4 小時。下面是發生了什麼。

    踩坑 1:macOS 沒有 flock

    第一次跑 sync.sh

    ./sync.sh: line 23: flock: command not found
    LOCK_FILE: unbound variable

    兩個錯一起爆,因為 set -euo pipefailflock 失敗放大成整段 die,連帶 unbound variable 連環炸。

    根因: flock 是 Linux util-linux 的 utility,macOS 沒有。GNU flock 可以 brew install flock 補,但這個工具的目標是 zero-dep。

    解法:mkdir-based 原子鎖

    LOCK_DIR="/tmp/claude-self-sync.lock.d"
    

    acquire_lock() {
    if mkdir "$LOCK_DIR" 2>/dev/null; then
    echo $$ > "$LOCK_DIR/pid"
    trap 'rm -rf "$LOCK_DIR"' EXIT
    return 0
    fi
    # 鎖已存在,看是不是 stale(pid 死了沒)
    if [ -f "$LOCK_DIR/pid" ]; then
    local old_pid
    old_pid=$(cat "$LOCK_DIR/pid")
    if ! kill -0 "$old_pid" 2>/dev/null; then
    echo "⚠️ 發現殭屍鎖(pid $old_pid 已死),清掉重來"
    rm -rf "$LOCK_DIR"
    mkdir "$LOCK_DIR"
    echo $$ > "$LOCK_DIR/pid"
    trap 'rm -rf "$LOCK_DIR"' EXIT
    return 0
    fi
    fi
    echo "❌ 已有另一個 sync 在跑(pid $(cat "$LOCK_DIR/pid" 2>/dev/null))"
    return 1
    }

    mkdir 在 POSIX 系統是原子操作。要嘛建成功(拿到鎖)、要嘛失敗(鎖已存在)。再加 PID 檢查清掉殭屍鎖,等於手寫 flock。

    教訓: 跨平台 bash 工具,先列「會用到但 macOS / Linux 不一致」的指令清單,不要寫到一半才發現。

    踩坑 2:openrsync 的 unlinkat 噪音

    跑 push 看到這個:

    rsync: [delete] unlinkat "claude-config/skills/foo/__pycache__": Directory not empty
    rsync error: some files/attrs were not transferred (code 23)

    實際看 repo,檔案都到位了,但 exit code 23,set -e 直接 die。

    根因: macOS 內建的 rsync 其實是 openrsync(Apple 自己 fork 的,不是 GNU rsync),偶爾會在 cleanup 階段噴這個警告。傳輸實際成功,但 exit code 不是 0。

    第一個解法(失敗): brew install rsync 換 GNU 版。結果 brew 自己壞掉:

    Error: undefined method initialize' for class Homebrew::AbstractCommand

    brew 也修不了(官方議題討論中),跳過。

    第二個解法(成功):noise filter wrapper

    RSYNC_FILTER='unlinkat.*Directory not empty|some files/attrs were not transferred \(code 23\)'
    

    rsync_quiet() {
    local errfile rc=0
    errfile=$(mktemp)
    command rsync "$@" 2>"$errfile" || rc=$?
    local filtered
    filtered=$(grep -vE "$RSYNC_FILTER" "$errfile" || true)
    [ -n "$filtered" ] && echo "$filtered" >&2
    rm -f "$errfile"
    # 如果只噴了已知噪音,還原成 0
    if [ "$rc" -ne 0 ] && [ -z "$filtered" ]; then return 0; fi
    return $rc
    }

    把 stderr 抓起來,過濾已知噪音,剩下沒東西就當 0。簡單暴力,問題消失。

    教訓: macOS 工具不一定跟 Linux 同名版本相同。man rsync 在 macOS 第一段就會寫 openrsync,但你不會去看 man。

    踩坑 3:中文全形 把 bash 變數名吃掉

    寫到一半冒出:

    ./sync.sh: line 187: DRY_RUN: unbound variable

    但我明明在 line 12 就 DRY_RUN="",且 set -u 對未定義變數才會觸發,定義為空字串應該沒事。

    trace 半天找到罪犯:

    echo "(dry-run 模式$DRY_RUN)"

    bash 把 當變數名的延續字元。 它 parse 成 ${DRY_RUN)},然後找不到結尾,當成 $DRY_RUN) 的整個 token,然後因為 set -u 對「複合變數參考」處理跟單純 $VAR 不同,觸發 unbound。

    寫中文註解 / 中文輸出習慣的人都會踩。修法:

    echo "(dry-run 模式${DRY_RUN})"

    ${VAR} 形式有明確邊界,bash 不會被全形字元矇騙。

    我寫了個 grep 全檔掃:

    grep -nE '\$[A-Za-z_][A-Za-z0-9_]*[)」』}]' sync.sh

    抓出 3 處同樣問題,全改 ${VAR}

    教訓: 寫中文 bash 註解 / 訊息,所有變數一律 ${VAR} 形式,不要圖懶寫 $VAR

    踩坑 4:ls glob + set -o pipefail 自殺事件

    備份保留邏輯:

    ls -t ~/.claude/backups/sync-pull-*.tar.gz 2>/dev/null | tail -n +4 | xargs -r rm

    意圖:列出所有備份按時間排序,從第 4 個開始砍掉(保留最新 3 份)。

    第一次跑(還沒任何備份):

    xargs: -r: illegal option

    兩個問題:

  • macOS xargs 沒有 GNU 的 -r(empty input 時跳過)。

  • ls glob 在 glob 沒匹配時 exit 1,加上 set -o pipefail,整個 pipe 變失敗,set -e 殺掉整個 script。
  • 解法:用 find 取代

    find ~/.claude/backups -maxdepth 1 -name 'sync-pull-*.tar.gz' -print0 \
      | xargs -0 ls -t \
      | tail -n +4 \
      | xargs -I{} rm -f {} || true

    find 沒匹配時 exit 0、用 -print0 / -0 處理含空白檔名、結尾 || true 兜底。

    教訓:

    • set -o pipefaills 是地雷組合
    • macOS xargs 跟 GNU xargs 不同(沒有 -r、沒有 -d
    • 寫 cross-platform bash,永遠用 find 取代 ls glob

    踩坑 5:rsync --delete 差點清掉今天的工作

    pull 一開始用 mirror 模式:

    rsync -a --delete "$CONFIG_REPO/" "$CLAUDE_DIR/"

    直觀邏輯:repo 是 source of truth,本機 mirror 它。

    某天我在 A 機寫了:

    • 新版 sync.sh
    • mempalace-healthcheck.sh
    • 3 個 memory 檔
    • transcript-bullets skill
    • vtt.md command
    忘記 push。下午跑 pull,rsync 看到「repo 沒這些 → 刪掉」。

    幸好我加了 --dry-run 預跑,看到輸出 *deleting sync.sh 那行,當場按 Ctrl+C。

    從那天起加了預掃機制:

    preflight_pull_deletes() {
      local repo="$1" dest="$2"
      local deletes
      deletes=$(rsync -a --delete -n -i \
        "${PULL_OPTS[@]}" "${repo}/" "${dest}/" \
        | grep '^*deleting' || true)
    

    if [ -n "$deletes" ]; then
    echo "⚠️ 以下檔案會被刪掉:"
    echo "$deletes"
    if [ "$FORCE" != "true" ]; then
    echo "❌ 拒絕執行。確認可以刪請加 --force,或先 push 你本機的東西"
    return 1
    fi
    fi
    }

    跑真實 pull 之前先用 -n -i dry-run 列出 *deleting 的行。有任何刪除 → 預設拒絕,要 --force 才繼續。

    加上 pull 前 tar -czf ~/.claude/backups/sync-pull-{timestamp}.tar.gz ~/.claude/,保留最近 3 份。就算 --force 出事,至少有 tar 可以救。

    教訓: 任何 destructive operation(--delete / rm -rf / git reset --hard)都要:

  • 預掃 — 告訴使用者要動什麼

  • 要求明確同意 — 不能是預設行為

  • 可逆 — 備份在前
  • 踩坑 6:dest repo 殘留 __pycache__ 讓 rsync 卡死

    push 過幾次後突然 hang:

    rsync: [generator] cannot delete non-empty directory: ...skills/foo/__pycache__

    rsync exclude 是有設 __pycache__/,但 --delete 邏輯是「source 沒有 → dest 刪掉」,dest(repo)裡的 __pycache__/ 雖然不在 exclude 該被清的範圍,可是又被 exclude pattern 擋住處理,產生詭異 race。

    解法:push 前先清 dest 的歷史殘留

    clean_repo_garbage() {
      local repo="$1"
      find "$repo" -type d \( \
        -name '__pycache__' -o -name '.git' -o -name 'node_modules' \
        \) -not -path "$repo/.git" -prune -exec rm -rf {} + 2>/dev/null || true
      find "$repo" -type f \( \
        -name '*.pyc' -o -name '.DS_Store' \
        \) -delete 2>/dev/null || true
    }

    push 前先掃一遍 dest,把任何符合 exclude 的歷史殘留乾掉,rsync 再跑就乾淨了。

    -not -path "$repo/.git" -prune 是為了不要把 repo 自己的 .git/ 也刪掉(那會把 repo 變成普通資料夾)。

    教訓: rsync exclude 規則只管「source → dest 的傳輸」,不管「dest 已經存在的東西」。要主動清理。

    跟 Claude Code 協作的工作流

    這 4 小時不是我自己 trace bug,是跟 Claude Code(Opus 4.7)配對 debug。實際工作流:

    第一階段:planner agent 開實作計畫

    你: ultrathink 想清楚 然後優化我的 /sync 到最佳狀態
    Claude: [拉 planner agent,列出 7 個優化方向 + 分階段]

    planner 把「優化」這個模糊需求拆成可執行的 task list:對齊 lock 機制、加 dry-run、加備份、加預掃、處理 macOS 差異、寫測試指令、抽 markdown。

    第二階段:我一邊改 Claude 一邊 review

    每寫一段就跟 Claude 確認:

    我: rsync 那段我先 wrap 起來抓 stderr 行不行
    Claude: 可以,但你 grep -vE 之後沒考慮到「真錯誤跟噪音同時出現」的情境,建議...
    我: 對 fix

    不是 Claude 寫 code 我貼上,是我寫 code Claude 當 reviewer + rubber duck。bash 這種「魔鬼在細節」的東西,pair 比 solo 快很多。

    第三階段:ralph-loop 自動跑到完成

    最關鍵的一步。卡在「優化完了,要不要開源?開源前還有什麼要做?」這種無限延伸的工作,我直接:

    /ralph-wiggum:ralph-loop 把/sync完成優化之後 確認沒問題就開源 直到開源都完成 --max-iterations 20

    ralph-loop 是個 skill,會把同一個 prompt 重新餵給 Claude,直到 Claude 自己宣告完成或達到 max iteration。實際只跑了 6 輪:

  • 跑 push 真實測試(中間踩到坑 → fix)

  • 跑 pull 真實測試(中間踩到坑 → fix)

  • 寫 README + INSTALL + LICENSE

  • 開 GitHub repo

  • push 上 GitHub

  • 寫 reference memory 紀錄
  • 沒有我每輪逐句指導。Claude 自己看上輪結果決定下一步該做什麼。這比「我下一句指令、Claude 回應」的對話節奏快 3 倍以上。

    但前提是任務邊界要明確(「直到開源都完成」)+ 給上限(--max-iterations 20)+ 你要信任 Claude 對「完成」的判斷。

    開源前的 checklist

    把工具丟到 public GitHub 之前,我跑了這個檢查:

    • [x] 沒有任何 hardcoded path 含 /Users/yanchen/(grep 全檔,全用 $HOME 或可配置變數)
    • [x] 沒有 token / API keygrep -iE 'token|api[_-]?key|password|secret' sync.sh
    • [x] 沒有 personal repo URL(README 範例用 placeholder)
    • [x] set -euo pipefail 真的有兜對(測試各種失敗路徑)
    • [x] dry-run 真的不寫東西(用 --dry-run 跑一輪,git status 應該乾淨)
    • [x] README 寫清楚 NOT goals(不要讓人誤以為這是給多人用的同步方案)
    • [x] LICENSE 加上(MIT,跟我其他 repo 一致)
    • [x] .gitignore 排除噪音.DS_Store*.bak.*__pycache__/
    • [x] 第一次 push 之後立刻看 GitHub 渲染(README table 對齊?code block 語法?連結通?)
    最後一條最重要。GitHub 的 markdown renderer 跟你 local IDE 渲染不完全一樣。實際看才知道。

    常見問題 FAQ

    Q1:為什麼不用 Python 寫,bash 不是很難 maintain?
    這個工具的核心 90% 是「呼叫 rsync / git / find」,Python 寫只是把這些指令 wrap 在
    subprocess.run,沒有任何抽象增益反而多一層依賴。Bash 對這種 shell-orchestration task 是 right tool。500 行內 maintain 沒問題。

    Q2:你怎麼決定哪些東西該 wrap、哪些該維持 inline bash?
    凡是「有重複呼叫」或「需要過濾輸出」就 wrap(例如
    rsync_quiet)。一次性的 inline 就好。函式名用動詞開頭(acquire_lockclean_repo_garbagepreflight_pull_deletes)讓 grep grep -n '^[a-z_]*()' sync.sh` 列出全函式像目錄。

    Q3:為什麼用 ralph-loop 不是直接讓 Claude 一路跑?
    直接讓 Claude 一路跑,沒有「重新 prompt」的節點,Claude 會在某個 ambiguous 點停下來問你。ralph-loop 的設計是「相同 prompt 重複觸發」,等於告訴 Claude「這個 prompt 還沒滿足,繼續」,避免他停下來等你確認瑣事。代價是你要相信你的 prompt 描述夠清楚,Claude 不會跑歪。

    Q4:開源後有人會用嗎?
    我不知道也不太在意。這個工具是寫給自己用的,「開源」只是順便的副作用——讓未來找類似方案的人少走冤枉路。如果剛好對你有用,那就拿去;不適合你也不要勉強。Personal tools 開源的價值不在「使用者數」而在「思考過程公開」。

    Q5:可以拿來改一改變成公司用嗎?
    MIT license 隨便改。但公司多人 sync 是另一個問題(permission、audit、conflict resolution、multi-tenancy),這個工具沒那個 scope。我會建議公司情境改用 dotfile manager + secret manager 組合,或乾脆 internal Plugin marketplace。

    延伸資源

    author
    陳彥彤

    AI 工程師 · AI 顧問。Java 後端 8 年、AI 工程師 2 年。AI 內訓 · AI 導入顧問 · 前後端與雲端培訓。

    support

    覺得文章有用可以到 GitHub 給個 star,或是透過信箱聊聊 AI 內訓、AI 導入顧問或前後端 / 雲端培訓。