Symlinked skills are mis-attributed to the enclosing agent-home git repo, splitting one skill into two rows
Summary
When an agent home (e.g. ~/.claude) is kept inside the user's own dotfiles git repo, a skill that was installed into the shared ~/.agents/skills/<name> location and symlinked into ~/.claude/skills/<name> is mis-attributed: the scanner walks the symlink's textual parents, hits ~/.claude/.git, and records the dotfiles repo as the skill's source. The git-source backfill in store.rs then stamps that copy with install_type = 'git' + the dotfiles install_url/pack.
Result: the single on-disk skill (one path is literally a symlink to the other) shows up as two rows in Extensions β one correctly sourced from the marketplace repo (the agents that scan ~/.agents/skills directly), and one falsely sourced from the user's dotfiles repo (Claude, via the symlink).
Reproduce
- Keep
~/.claude under a personal dotfiles git repo (a common backup setup).
- Install a multi-agent skill that uses the shared layout, e.g.
npx skills@latest add mattpocock/skills. This creates the canonical copy at ~/.agents/skills/tdd and a symlink ~/.claude/skills/tdd -> ../../.agents/skills/tdd.
- Open Extensions and filter for the skill.
Expected: one row, all agents, source = mattpocock/skills, updatable together.
Actual: two rows β mattpocock/skills (Codex/Gemini/Copilot) and <you>/<dotfiles-repo> (Claude, install_type=git).
Live DB confirms (~/.harnesskit/metadata.db):
| agent |
source.url |
pack |
install_url |
install_type |
| claude |
<dotfiles> |
<you>/<dotfiles> |
<dotfiles> |
git |
| codex/gemini/copilot |
null |
mattpocock/skills |
mattpocock/skills |
marketplace |
Root cause
scanner::detect_source walks textual parent dirs for .git. For a symlinked skill entry it finds the repo the link sits under, not the one the content lives in.
store.rs sync_extensions / sync_extensions_for_agent then backfill install_type='git' from that polluted source_json.
- Grouping keys on the derived developer (
install_meta.url β source.url β pack), so the two copies fall into different groups.
This also defeats #76 (prefer install_meta.url for grouping): here install_meta itself is the wrong dotfiles URL, so preferring it still yields the wrong source.
Proposed fix
- Scanner: resolve symlinks (
canonicalize) before walking up for .git, so a symlinked skill is attributed to its real content's source. Plain skills canonicalize to the same tree β unchanged.
- Store (self-heal existing DBs): clear the bogus
git install_meta (and the pack derived from it) for skill rows whose on-disk entry is a symlink and whose freshly-scanned source is non-git. Real git installs are plain files (never symlinks), so they are untouched.
After both, the symlinked Claude copy scans as sourceless and re-attaches to its marketplace sibling via the existing urlSiblings grouping pass β one row, updatable together.
PR incoming.
Symlinked skills are mis-attributed to the enclosing agent-home git repo, splitting one skill into two rows
Summary
When an agent home (e.g.
~/.claude) is kept inside the user's own dotfiles git repo, a skill that was installed into the shared~/.agents/skills/<name>location and symlinked into~/.claude/skills/<name>is mis-attributed: the scanner walks the symlink's textual parents, hits~/.claude/.git, and records the dotfiles repo as the skill's source. The git-source backfill instore.rsthen stamps that copy withinstall_type = 'git'+ the dotfilesinstall_url/pack.Result: the single on-disk skill (one path is literally a symlink to the other) shows up as two rows in Extensions β one correctly sourced from the marketplace repo (the agents that scan
~/.agents/skillsdirectly), and one falsely sourced from the user's dotfiles repo (Claude, via the symlink).Reproduce
~/.claudeunder a personal dotfiles git repo (a common backup setup).npx skills@latest add mattpocock/skills. This creates the canonical copy at~/.agents/skills/tddand a symlink~/.claude/skills/tdd -> ../../.agents/skills/tdd.Expected: one row, all agents, source =
mattpocock/skills, updatable together.Actual: two rows β
mattpocock/skills(Codex/Gemini/Copilot) and<you>/<dotfiles-repo>(Claude,install_type=git).Live DB confirms (
~/.harnesskit/metadata.db):<dotfiles><you>/<dotfiles><dotfiles>mattpocock/skillsmattpocock/skillsRoot cause
scanner::detect_sourcewalks textual parent dirs for.git. For a symlinked skill entry it finds the repo the link sits under, not the one the content lives in.store.rssync_extensions/sync_extensions_for_agentthen backfillinstall_type='git'from that pollutedsource_json.install_meta.url β source.url β pack), so the two copies fall into different groups.This also defeats #76 (prefer
install_meta.urlfor grouping): hereinstall_metaitself is the wrong dotfiles URL, so preferring it still yields the wrong source.Proposed fix
canonicalize) before walking up for.git, so a symlinked skill is attributed to its real content's source. Plain skills canonicalize to the same tree β unchanged.gitinstall_meta (and the pack derived from it) for skill rows whose on-disk entry is a symlink and whose freshly-scanned source is non-git. Real git installs are plain files (never symlinks), so they are untouched.After both, the symlinked Claude copy scans as sourceless and re-attaches to its marketplace sibling via the existing
urlSiblingsgrouping pass β one row, updatable together.PR incoming.