Problem
skills update reports this for every skill installed from a source repo that exposes one skill tree at two paths, and then skips them:
Warning: Multiple current paths match these skills from https://example.com/team/agent-skills.git; skipping them rather than deleting or migrating the wrong skill:
• snow-android-attestation-report
• snow-ios-attestation-reportThe skip has a real cost: those skills are never updated and are never considered for deletion, so they silently stay at whatever version was installed first.
A repo hits this as soon as it keeps skills in .agents/skills and symlinks .claude to .agents (a common layout for supporting both conventions), because both paths resolve to the same SKILL.md.
Root cause
discoverSkills(..., { fullDepth: true, includeDuplicateNames: true })deduplicates withresolve(), which is lexical and does not follow symlinks..agents/skills/x/SKILL.mdand.claude/skills/x/SKILL.mdare treated as two distinct skills.skills updateforces full-depth discovery for sources wheresupportsAppendedSubpath()is false — URLs ending in.git(generic git, e.g. GHES) and local paths.resolveSkillLocationsthen sees two discovered paths for one locked name.exactPathDisambiguatesis false in that full-depth mode, so even though the lockedskillPathstill exists, the skill is classified as ambiguous and dropped from both the update list and the deletion check.
GitHub sources avoid the issue because their subpath can be appended to the source URL, so discovery stays scoped.
Fix
Deduplicate on the canonical (realpath) SKILL.md path. The first discovered path is kept as the skill's location, so locked skillPath values still match and no relocation is reported.
Tests
tests/symlinked-skill-tree-discovery.test.ts:
- a repo with
.agents/skills/demo-skilland.claude -> .agentsis discovered once, still located at.agents/skills/demo-skill. - genuinely separate directories (
.agents/skills/dup-skillandskills/dup-skill) are still reported as two entries, so the dedupe only collapses symlink aliases.
Red/green: with const dedupeKey = skillMdPath; the first test fails (expected [...] to have a length of 1 but got 2); with the canonical key both pass.
Verification
End-to-end against a real git source (GHES repo with .claude -> .agents) and lock file, same machine and same command:
- before:
Warning: Multiple current paths match these skills from …then✓ All global skills are up to date - after: no warning, and the two stale skills were updated (
Found 2 global update(s)→Updated 2 skill(s))
pnpm type-check and pnpm format:check are clean.
pnpm test on macOS shows 9 failures in src/detect-agent.test.ts (3) and tests/invalid-local-lock.test.ts (6); both fail identically on the unmodified checkout at 9197d91 (tmpdir /var vs /private/var path comparison and Cursor env detection), so they are unrelated to this change and should pass on the ubuntu/windows CI matrix.