From 24e8a6e701ea620f493e5573823188d3858368d0 Mon Sep 17 00:00:00 2001 From: Teknium Date: Thu, 23 Apr 2026 04:49:31 -0700 Subject: [PATCH] feat(skills_sync): surface collision with reset-hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a newly-bundled skill's name collides with a pre-existing user skill, sync silently kept the user's copy. Users never learned that a bundled version shipped by that name. Now (on non-quiet sync only) print: ⚠ : bundled version shipped but you already have a local skill by this name — yours was kept. Run `hermes skills reset ` to replace it with the bundled version. No behavior change to manifest writes or to the kept user copy — purely additive warning on the existing collision-skip path. --- tests/tools/test_skills_sync.py | 22 ++++++++++++++++++++++ tools/skills_sync.py | 7 +++++++ 2 files changed, 29 insertions(+) diff --git a/tests/tools/test_skills_sync.py b/tests/tools/test_skills_sync.py index 668c4c74e..347366e6a 100644 --- a/tests/tools/test_skills_sync.py +++ b/tests/tools/test_skills_sync.py @@ -460,6 +460,28 @@ class TestSyncSkills: "as 'user-modified' — the manifest was poisoned on the first sync." ) + def test_collision_prints_reset_hint(self, tmp_path, capsys): + """Non-quiet sync must print a reset hint when a collision is skipped. + + Silent skip hides the fact that a bundled skill shipped but was + shadowed by the user's local copy. The hint tells the user the + exact command to take the bundled version instead. + """ + bundled = self._setup_bundled(tmp_path) + skills_dir = tmp_path / "user_skills" + manifest_file = skills_dir / ".bundled_manifest" + + user_skill = skills_dir / "category" / "new-skill" + user_skill.mkdir(parents=True) + (user_skill / "SKILL.md").write_text("# From hub — unrelated to bundled") + + with self._patches(bundled, skills_dir, manifest_file): + sync_skills(quiet=False) + + captured = capsys.readouterr().out + assert "new-skill" in captured + assert "hermes skills reset new-skill" in captured + def test_nonexistent_bundled_dir(self, tmp_path): with patch("tools.skills_sync._get_bundled_dir", return_value=tmp_path / "nope"): result = sync_skills(quiet=True) diff --git a/tools/skills_sync.py b/tools/skills_sync.py index 3acaf2fbb..cb7955c01 100644 --- a/tools/skills_sync.py +++ b/tools/skills_sync.py @@ -218,6 +218,13 @@ def sync_skills(quiet: bool = False) -> dict: skipped += 1 if _dir_hash(dest) == bundled_hash: manifest[skill_name] = bundled_hash + elif not quiet: + print( + f" ⚠ {skill_name}: bundled version shipped but you " + f"already have a local skill by this name — yours " + f"was kept. Run `hermes skills reset {skill_name}` " + f"to replace it with the bundled version." + ) else: dest.parent.mkdir(parents=True, exist_ok=True) shutil.copytree(skill_src, dest)