Skip to content

fix(skills): attribute symlinked skills to real source, not enclosing repo#88

Merged
RealZST merged 1 commit into
RealZST:mainfrom
SherlockSalvatore:fix/symlinked-skill-source-attribution
Jun 25, 2026
Merged

fix(skills): attribute symlinked skills to real source, not enclosing repo#88
RealZST merged 1 commit into
RealZST:mainfrom
SherlockSalvatore:fix/symlinked-skill-source-attribution

Conversation

@SherlockSalvatore

@SherlockSalvatore SherlockSalvatore commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Fixes #87.

Problem

When an agent home (e.g. ~/.claude) is kept inside a personal dotfiles git repo, a skill 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 row with install_type='git' + the dotfiles url/pack.

Result: one on-disk skill (one path is literally a symlink to the other) appears as two rows in Extensions — one correctly sourced from the marketplace repo (Codex/Gemini/Copilot, which scan ~/.agents/skills directly) and one falsely sourced from the user's dotfiles repo (Claude, via the symlink). The two can't be updated together.

Fix

  1. scannercanonicalize the skill path before detect_source, so a symlinked skill is attributed to its real content's source. Plain (non-symlinked) skills resolve to the same tree, so detection is unchanged.
  2. 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're untouched. Runs before backfill_packs so cleared rows don't re-acquire a pack.

After both, the symlinked Claude row scans as sourceless and re-attaches to its marketplace sibling via the existing urlSiblings grouping pass → one row, all agents, updatable together.

Testing

  • cargo test -p hk-core — 529 passed, incl. two new regression tests:
    • scanner::test_symlinked_skill_attributed_to_real_source_not_enclosing_repo
    • store::test_sync_heals_symlinked_git_install_meta — covers active + disabled symlinked skills (healed) and both a plain-file git install and a git-backed symlink (preserved).
  • cargo clippy -p hk-core — clean for the touched files.

Known limitations (out of scope)

  • If the shared ~/.agents tree is itself a git repo, all agents canonicalize to the same git source and group into a single (correctly unified, though dotfiles-labeled) row — not a fork. Resolving the marketplace source there would require reading the skills CLI lockfile, which HarnessKit does not consume today.
  • sync_extensions_for_agent heals only the agent's current scan batch; a stale row for another agent is healed on that agent's next sync.

Scope & follow-up

This PR fixes the symlinked-skill slice of a broader source-attribution family. They share one root cause: scanner::detect_source infers an extension's source by walking up to the nearest enclosing .git, which is wrong whenever the files live inside the user's own dotfiles repo (e.g. ~/.claude/.git). Other faces of the same bug are not addressed here and will be fixed at the root in a follow-up:

  • Plugins cached under ~/.claude/plugins/cache/<owner>/<repo>/ are real directories inside the dotfiles repo, so they are all mis-attributed to it (their real marketplace is recorded in Claude's own installed_plugins.json). Symlink resolution can't help here — there is no symlink.
  • ~/.agents kept under git, or a marketplace skill copied as a real directory into ~/.claude, hit the same enclosing-.git inference.

The intended root fix is to derive tool-managed extensions' source from each tool's install manifest (~/.agents/.skill-lock.json for skills, ~/.claude/plugins/installed_plugins.json for plugins) and fall back to detect_source only for genuinely git-cloned extensions. That work is tracked separately; this PR is the immediate, low-risk fix for the symlinked-skill case and stays intentionally narrow.

… repo

A skill installed into the shared ~/.agents/skills and symlinked into an
agent home (e.g. ~/.claude/skills) was mis-attributed when that home sits
inside a dotfiles git repo: detect_source walked the symlink's textual
parents, hit ~/.claude/.git, and recorded the dotfiles repo as the source.
The git-source backfill then stamped install_type='git' + pack, forking one
on-disk skill into two Extension rows (marketplace vs dotfiles).

- scanner: canonicalize the skill path before detect_source so a symlinked
  skill is attributed to its real content's source; plain skills resolve to
  the same tree and are unchanged.
- store: self-heal existing DBs by clearing 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. Run before backfill_packs so
  cleared rows do not re-acquire a pack.

Fixes RealZST#87
SherlockSalvatore added a commit to SherlockSalvatore/HarnessKit that referenced this pull request Jun 24, 2026
HarnessKit inferred an extension's source by walking up to the nearest
enclosing .git, mis-attributing everything under a dotfiles-managed agent
home (e.g. ~/.claude) to that backup repo. Read each tool's own install
manifest instead, falling back to .git detection only when absent.

- Skills: override detect_source with <root>/.skill-lock.json (the skills
  CLI lockfile), matched by on-disk folder name, cached per lockfile.
- Plugins: PluginEntry gains source_url; the Claude adapter fills it from
  plugins/known_marketplaces.json (github marketplaces -> owner/repo);
  scan_plugins prefers it over the .git walk. Other adapters unchanged.

Regression tests: lockfile beats a populated enclosing repo (and a sibling
skill absent from the lock falls back); a plugin is attributed to its
marketplace repo.

Refs RealZST#89. Builds on RealZST#88 canonicalize in scan_skill_dir.
SherlockSalvatore added a commit to SherlockSalvatore/HarnessKit that referenced this pull request Jun 24, 2026
…ource

The scanner now resolves the real source from install manifests, but the
git-source backfill only writes install_meta when install_type IS NULL, so a
row stamped in an earlier sync (e.g. a plugin first attributed to the
enclosing dotfiles repo) keeps its stale install_url. deriveExtensionUrl
prefers install_url, so the corrected source_json.url stayed shadowed and the
extension lingered in the wrong group.

Add refresh_stale_git_install_meta: for install_type='git' rows whose
install_url disagrees with the authoritative source_json.url, realign
install_url/revision and clear pack so backfill_packs re-derives it. Rows
already in agreement and non-git installs are untouched. Runs in both sync
paths after the symlink heal, before backfill_packs. This is the store half of
the manifest source-resolution fix (parallel to the RealZST#88 self-heal).
SherlockSalvatore added a commit to SherlockSalvatore/HarnessKit that referenced this pull request Jun 24, 2026
The scanner now resolves the real source from install manifests, but the
git-source backfill only writes install_meta when install_type IS NULL, so a
row stamped in an earlier sync (e.g. a plugin first attributed to the
enclosing dotfiles repo) keeps its stale install_url. deriveExtensionUrl
prefers install_url, so the corrected source_json.url stayed shadowed and the
extension lingered in the wrong group.

Add refresh_stale_git_install_meta: for install_type='git' skill/plugin rows
whose install_url owner/repo differs from the authoritative source_json.url
owner/repo, realign install_url/revision and clear the now-stale
branch/subpath + pack (re-derived by backfill_packs). Compare by pack, not raw
URL string, so a legitimate install recorded as ".../repo" is not churned
against the scanner's ".../repo.git" remote every sync (which would wipe its
pinned revision and check state). Runs in both sync paths after the symlink
heal, before backfill_packs. This is the store half of the manifest
source-resolution fix (parallel to the RealZST#88 self-heal).
@RealZST RealZST merged commit a6a1e96 into RealZST:main Jun 25, 2026
3 checks passed
RealZST pushed a commit to SherlockSalvatore/HarnessKit that referenced this pull request Jun 25, 2026
HarnessKit inferred an extension's source by walking up to the nearest
enclosing .git, mis-attributing everything under a dotfiles-managed agent
home (e.g. ~/.claude) to that backup repo. Read each tool's own install
manifest instead, falling back to .git detection only when absent.

- Skills: override detect_source with <root>/.skill-lock.json (the skills
  CLI lockfile), matched by on-disk folder name, cached per lockfile.
- Plugins: PluginEntry gains source_url; the Claude adapter fills it from
  plugins/known_marketplaces.json (github marketplaces -> owner/repo);
  scan_plugins prefers it over the .git walk. Other adapters unchanged.

Regression tests: lockfile beats a populated enclosing repo (and a sibling
skill absent from the lock falls back); a plugin is attributed to its
marketplace repo.

Refs RealZST#89. Builds on RealZST#88 canonicalize in scan_skill_dir.
RealZST pushed a commit to SherlockSalvatore/HarnessKit that referenced this pull request Jun 25, 2026
The scanner now resolves the real source from install manifests, but the
git-source backfill only writes install_meta when install_type IS NULL, so a
row stamped in an earlier sync (e.g. a plugin first attributed to the
enclosing dotfiles repo) keeps its stale install_url. deriveExtensionUrl
prefers install_url, so the corrected source_json.url stayed shadowed and the
extension lingered in the wrong group.

Add refresh_stale_git_install_meta: for install_type='git' skill/plugin rows
whose install_url owner/repo differs from the authoritative source_json.url
owner/repo, realign install_url/revision and clear the now-stale
branch/subpath + pack (re-derived by backfill_packs). Compare by pack, not raw
URL string, so a legitimate install recorded as ".../repo" is not churned
against the scanner's ".../repo.git" remote every sync (which would wipe its
pinned revision and check state). Runs in both sync paths after the symlink
heal, before backfill_packs. This is the store half of the manifest
source-resolution fix (parallel to the RealZST#88 self-heal).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Symlinked skills mis-attributed to enclosing agent-home git repo, splitting one skill into two rows

2 participants