#2671

fix(discovery): collapse symlinked skill paths to one location

Opencj848wants to merge intofrom

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-report

The 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

  1. discoverSkills(..., { fullDepth: true, includeDuplicateNames: true }) deduplicates with resolve(), which is lexical and does not follow symlinks. .agents/skills/x/SKILL.md and .claude/skills/x/SKILL.md are treated as two distinct skills.
  2. skills update forces full-depth discovery for sources where supportsAppendedSubpath() is false — URLs ending in .git (generic git, e.g. GHES) and local paths.
  3. resolveSkillLocations then sees two discovered paths for one locked name. exactPathDisambiguates is false in that full-depth mode, so even though the locked skillPath still 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-skill and .claude -> .agents is discovered once, still located at .agents/skills/demo-skill.
  • genuinely separate directories (.agents/skills/dup-skill and skills/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.

Activity

2