claude --worktree 里 skill 不见了:一个 symlink 拷不动的坑
上一篇我讲了 skills-lock.json 怎么把一堆从别人仓库拉来的 skill 锁成可复现的依赖。这篇是它的一个直接后果:正因为我的 skill 是用 skills CLI 那套 .agents/ + symlink 机制物化的,我一开 claude --worktree 就踩了坑——项目级 skill 在 worktree 里整个失效了。
现象:在主仓里好好的两个项目 skill(
impeccable、shadcn),我用claude --worktree开一个新会话窗口,它们就从可用列表里消失了。 根因:这俩 skill 在.claude/skills/下其实是符号链接,指回.agents/skills/的真身;而claude --worktree往新 worktree 拷文件时跳过了 symlink,于是.claude/skills/整个目录在 worktree 里压根不存在。 根治:用skills add --copy -a claude把 skill 物化成真实文件直接落在.claude/skills/,worktree 拷真实目录没问题。
下面按「先搞清两个机制 → 定位根因 → 临时救 → 根治」捋一遍。仓库名我做了脱敏,路径都是 skills CLI / Claude Code 的通用约定,你照着对自己的就行。
一、先搞清两个机制:skill 怎么被发现、worktree 怎么拷文件
这个坑是两套机制叠在一起才出现的,得先把它们分开看。这两条都是官方文档写明的:
1)Claude Code 怎么发现项目级 skill。 它扫的是 .claude/skills/<name>/SKILL.md——从你启动的目录一路往上找到仓库根,每层的 .claude/skills/ 都算。命令名就是那个目录名(.claude/skills/deploy/SKILL.md → /deploy)。关键点:它只认 .claude/skills/ 这个路径。
2)claude --worktree 怎么起会话。 它在 .claude/worktrees/<name>/ 建一个新的 git worktree、切到 worktree-<name> 分支,然后在里面开会话。问题是 git worktree 不会带上被 gitignore 的文件——而我的 skill 恰恰是 gitignore 的。为此 Claude Code 给了一个 .worktreeinclude 文件(放仓库根、用 .gitignore 语法),列出「虽然被 ignore、但请帮我拷进新 worktree」的路径。
我的 .worktreeinclude 里就老老实实写了两行:
.agents/skills/
.claude/skills/
按理说两个都该被拷进 worktree。但实际不是。
二、定位根因:我的 skill 是 symlink,而 worktree 拷贝跳过了它
先交代我的 skill 是怎么组织的——这正是 上一篇 skills-lock 里那套 skills CLI 的物化机制:
- 真身在
.agents/skills/(impeccable、shadcn的真实文件落在这); .claude/skills/下放的是符号链接,指回真身:
.claude/skills/impeccable -> ../../.agents/skills/impeccable
.claude/skills/shadcn -> ../../.agents/skills/shadcn
这是 skills CLI 的默认行为:真身存一份在 .agents/,各家 agent 目录(.claude/、.cursor/…)用 symlink 共享,省得每个工具各拷一份。两者都被 gitignore,所以 worktree 不自带,得靠 .worktreeinclude 补。
可我去新建的 worktree 里一核对,结果是这样:
| 路径 | 主仓 | worktree 里 |
|---|---|---|
.agents/skills/(真实目录) | ✅ | ✅ 拷过来了 |
.claude/skills/(纯 symlink) | ✅ | ❌ 整个目录都不存在 |
因果链就齐了:.claude/skills/ 下全是 symlink → worktree 拷贝把它整体跳过、连目录都没建 → 而 Claude 发现项目 skill 偏偏只扫 .claude/skills/,目录没了,impeccable / shadcn 自然加载不出来。 .agents/skills/ 的真身虽然拷过来了,但 Claude 不扫那个路径,等于「有用但没用上」。
这里要把官方和我自己的观察分开说清楚:
- 官方写明的:
.worktreeinclude拷的是「匹配且被 gitignore 的文件」。 - 文档没明说、我实测出来的:当一个目录里全是 symlink 时,这次拷贝不会把它物化过来(真实目录就没事)。我没在文档里找到对 symlink 行为的说明,而 Claude Code 仓库里有一个相关 issue(#28041)报告
.claude/下的子目录拷不进 worktree——和我看到的对得上。所以这条姑且当疑似 bug 的观察,别当成稳定契约,哪天修了行为可能就变了。
三、临时救一下当前 worktree
真身 .agents/skills/ 既然已经在 worktree 里了,最快的止血就是把缺的那层 symlink 手动补回来:
mkdir -p .claude/skills
ln -sfn ../../.agents/skills/impeccable .claude/skills/impeccable
ln -sfn ../../.agents/skills/shadcn .claude/skills/shadcn
补完在会话里跑一下 /reload-skills,impeccable、shadcn 就回到可用列表了。
但这只救当前这一个 worktree。每开一个新 worktree 都会重犯(symlink 又被跳过),总不能每次手敲三行 ln。得根治。
四、能不能丢掉 .agents,只用 .claude?
我当时的第一反应是:那我干脆不要 .agents/ 这一层 symlink,让 skill 直接住在 .claude/skills/ 里不就完了?顺着这个想法查清楚后,先得搞明白 .agents/ 是谁、凭什么在这。
.agents/ 不是 Claude Code 的东西,是 skills CLI(vercel-labs/skills)的约定:
.agents/skills/= 跨 agent 的真身,skill 的真实文件落在这;- 各家 agent 目录默认用 symlink 指过去,这样同一份 skill 多个工具共用、不重复;
skills-lock.json里的skillPath也写死指向.agents/skills/...。
所以这套 .agents/ + symlink 只有在你同时用多个 agent 工具时才有价值。而那个 symlink,正是 claude --worktree 拷不动的东西。 我只用 Claude,这层共用对我是纯负担。
丢掉它有两种做法,代价差很多:
- ① 顺着工具走(我选这个):让
.claude/skills/装真实文件而不是 symlink。skillsCLI 的add支持--copy(「拷贝而非建链接」)和-a claude(只装给 Claude)。这样.claude/skills/里就是真实目录,worktree 拷真实目录没问题 → 根治。 - ② 彻底删
.agents/、手改 lock 里的skillPath:能做,但skillsCLI 把.agents/当规范存储,以后只要你跑skills update/install,它会把.agents/和 symlink 重新生成出来——等于你得放弃用这套 CLI 管 skill。不划算。
五、根治:用 —copy 把 skill 物化成真实文件
我最后走的就是做法 ①。三步:
1)以 copy 模式重装这两个 skill,只装给 Claude、把真身拷进 .claude/skills/:
skills add pbakaus/impeccable --copy -a claude
skills add <shadcn 的来源> --copy -a claude
2)改 .worktreeinclude,把 .agents/skills/ 那行删掉,只留 .claude/skills/——现在它是真实文件,能被拷:
- .agents/skills/
.claude/skills/
3)验证。 开一个新的 claude --worktree 会话,确认 .claude/skills/ 这次真的被拷了进去、impeccable / shadcn 直接在可用列表里——不用再手补 symlink。
这样以后每个新 worktree 都自带 skill,坑就堵死在源头了。
六、保留的几点判断
- symlink 被跳过是不是 bug,我没有定论。 文档没写明,我只是把它和 issue #28041 对上了。如果哪天官方让 worktree 拷贝跟随 symlink,这个坑会自己消失,但在那之前,「真实文件」是文档保证能拷的稳妥路径,symlink 不是。真要确认根治,务必开一个全新 worktree 验一遍,别只在打过补丁的旧 worktree 里看。
--copy的代价是放弃了「单一真身、多工具共用」。 每个 agent 目录各存一份真实拷贝,占多点空间、更新时也是各更各的。我只用 Claude,这个代价等于零;你要是 Claude + Cursor 混着用,得自己掂量——可能更愿意保留.agents/、转而去给 worktree 写个补 symlink 的 SessionStart hook。skillsCLI 本身还嫩。 还原命令挂着experimental、remove不一定同步 lock——这些我在 skills-lock 那篇 里记过,--copy重装后记得扫一眼skills-lock.json有没有如实更新。
小结
这个坑的本质是两套机制在 symlink 上不咬合:
- Claude 发现项目 skill 只认
.claude/skills/<name>/SKILL.md(官方文档); claude --worktree按.worktreeinclude补拷 gitignore 文件,但纯 symlink 的目录会被整体跳过(我实测,疑似 #28041);- 而我的
.claude/skills/恰好全是指回.agents/skills/的 symlink——三件事一叠,skill 就在 worktree 里凭空消失了。
根治办法很朴素:用 skills add --copy -a claude 把 skill 物化成真实文件,worktree 才拷得动。代价是丢掉 .agents/ 那套多工具共用,但对只用 Claude 的我来说,换来「每个 worktree 都自带 skill」完全值。
又一次印证了那句老话:symlink 和「按文件拷贝」的工具,天生合不来。 下次再发现 worktree / 容器 / rsync 里某个目录莫名其妙没了,先看一眼它是不是个软链接。