# 我怎麼用 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 直接跑。這篇就是那一天的故事。
目錄
flockunlinkat 噪音) 把 bash 變數名吃掉ls glob + set -o pipefail 自殺事件--delete 差點清掉今天的工作__pycache__ 讓 rsync 卡死起點:為什麼要把腳本搬離 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 跑得起來,但問題堆積:
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 pipefail 把 flock 失敗放大成整段 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
兩個問題:
沒有 GNU 的 -r(empty input 時跳過)。 在 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 pipefail
配ls是地雷組合 - 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
,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 key( grep -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_lock、clean_repo_garbage、preflight_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。
延伸資源
- 🔗 claude-self-sync GitHub — 主 repo
- 🔗 上一篇:claude-self-sync 工具介紹 — 不是開發歷程,是使用指南
- 🔗 BashFAQ/045: Locking — mkdir-based mutex 細節
- 🔗 openrsync man page — macOS 內建 rsync 的真相
- 🔗 Ralph Wiggum loop pattern — Claude 自循環工作流參考(取名來源是辛普森家庭那個 Ralph)