claude --worktree 里 skill 不见了:一个 symlink 拷不动的坑

上一篇我讲了 skills-lock.json 怎么把一堆从别人仓库拉来的 skill 锁成可复现的依赖。这篇是它的一个直接后果:正因为我的 skill 是用 skills CLI 那套 .agents/ + symlink 机制物化的,我一开 claude --worktree 就踩了坑——项目级 skill 在 worktree 里整个失效了

现象:在主仓里好好的两个项目 skill(impeccableshadcn),我用 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/(impeccableshadcn 的真实文件落在这);
  • .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)整个目录都不存在

为什么 worktree 里 skill 不见了:主仓里 .agents/skills 是真实目录、.claude/skills 是指回它的 symlink;claude --worktree 按 .worktreeinclude 补拷时,真实目录 .agents/skills 拷过来了,但纯 symlink 的 .claude/skills 被整个跳过、目录都不存在;而 Claude 发现项目 skill 只扫 .claude/skills/*/SKILL.md,目录没了,impeccable 和 shadcn 自然加载不出来

因果链就齐了:.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,impeccableshadcn 就回到可用列表了。

但这只救当前这一个 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。skills CLI 的 add 支持 --copy(「拷贝而非建链接」)和 -a claude(只装给 Claude)。这样 .claude/skills/ 里就是真实目录,worktree 拷真实目录没问题 → 根治。
  • ② 彻底删 .agents/、手改 lock 里的 skillPath:能做,但 skills CLI 把 .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。
  • skills CLI 本身还嫩。 还原命令挂着 experimentalremove 不一定同步 lock——这些我在 skills-lock 那篇 里记过,--copy 重装后记得扫一眼 skills-lock.json 有没有如实更新。

小结

这个坑的本质是两套机制在 symlink 上不咬合:

  1. Claude 发现项目 skill 只认 .claude/skills/<name>/SKILL.md(官方文档);
  2. claude --worktree.worktreeinclude 补拷 gitignore 文件,但纯 symlink 的目录会被整体跳过(我实测,疑似 #28041);
  3. 而我的 .claude/skills/ 恰好全是指回 .agents/skills/ 的 symlink——三件事一叠,skill 就在 worktree 里凭空消失了。

根治办法很朴素:用 skills add --copy -a claude 把 skill 物化成真实文件,worktree 才拷得动。代价是丢掉 .agents/ 那套多工具共用,但对只用 Claude 的我来说,换来「每个 worktree 都自带 skill」完全值。

又一次印证了那句老话:symlink 和「按文件拷贝」的工具,天生合不来。 下次再发现 worktree / 容器 / rsync 里某个目录莫名其妙没了,先看一眼它是不是个软链接。