Skip to content

Commit 20edca7

Browse files
briandevansclaude
authored andcommitted
fix(update): sync bundled skills to all profiles, including active (#16176)
`hermes update` iterated only non-active profiles when seeding bundled skills. `seed_profile_skills()` uses a subprocess with an explicit HERMES_HOME so it correctly targets any profile path; the `p.name != active` filter was the only thing preventing the active profile from being included, leaving it silently on stale skill content after every update. Drop the filter and update the header line from "other profiles" to "all profiles". The active profile is now seeded on the same path as every other profile. The earlier `sync_skills()` call (module-level HERMES_HOME) remains for backward compatibility; the subprocess-based loop is reliable regardless of which HERMES_HOME the CLI was invoked with. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 103f51a commit 20edca7

2 files changed

Lines changed: 84 additions & 7 deletions

File tree

hermes_cli/main.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7069,20 +7069,22 @@ def _cmd_update_impl(args, gateway_mode: bool):
70697069
except Exception as e:
70707070
logger.debug("Skills sync during update failed: %s", e)
70717071

7072-
# Sync bundled skills to all other profiles
7072+
# Sync bundled skills to all profiles (including the active one).
7073+
# seed_profile_skills() uses subprocess with an explicit HERMES_HOME so
7074+
# it is not affected by sync_skills()'s module-level HERMES_HOME cache,
7075+
# which means the active profile is reliably synced regardless of whether
7076+
# the caller's HERMES_HOME env var points at the default or a named profile.
70737077
try:
70747078
from hermes_cli.profiles import (
70757079
list_profiles,
7076-
get_active_profile_name,
70777080
seed_profile_skills,
70787081
)
70797082

7080-
active = get_active_profile_name()
7081-
other_profiles = [p for p in list_profiles() if p.name != active]
7082-
if other_profiles:
7083+
all_profiles = list_profiles()
7084+
if all_profiles:
70837085
print()
7084-
print("→ Syncing bundled skills to other profiles...")
7085-
for p in other_profiles:
7086+
print("→ Syncing bundled skills to all profiles...")
7087+
for p in all_profiles:
70867088
try:
70877089
r = seed_profile_skills(p.path, quiet=True)
70887090
if r:

tests/hermes_cli/test_cmd_update.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,78 @@ def test_update_non_interactive_skips_migration_prompt(self, mock_args, capsys):
163163
mock_input.assert_not_called()
164164
captured = capsys.readouterr()
165165
assert "Non-interactive session" in captured.out
166+
167+
168+
class TestCmdUpdateProfileSkillSync:
169+
"""cmd_update syncs bundled skills to all profiles, including the active one.
170+
171+
Regression guard for #16176: previously the active profile was excluded
172+
from the seed_profile_skills loop, leaving it on stale skill content.
173+
"""
174+
175+
@patch("shutil.which", return_value=None)
176+
@patch("subprocess.run")
177+
def test_active_profile_included_in_skill_sync(
178+
self, mock_run, _mock_which, mock_args, capsys
179+
):
180+
from pathlib import Path
181+
182+
mock_run.side_effect = _make_run_side_effect(
183+
branch="main", verify_ok=True, commit_count="1"
184+
)
185+
186+
default_p = SimpleNamespace(name="default", path=Path("/fake/.hermes"))
187+
active_p = SimpleNamespace(name="bit", path=Path("/fake/.hermes/profiles/bit"))
188+
other_p = SimpleNamespace(name="work", path=Path("/fake/.hermes/profiles/work"))
189+
all_profiles = [default_p, active_p, other_p]
190+
191+
synced_paths = []
192+
193+
def fake_seed(path, quiet=False):
194+
synced_paths.append(path)
195+
return {"copied": [], "updated": [], "user_modified": []}
196+
197+
empty_sync = {"copied": [], "updated": [], "user_modified": [], "cleaned": []}
198+
199+
with (
200+
patch("hermes_cli.profiles.list_profiles", return_value=all_profiles),
201+
patch("hermes_cli.profiles.seed_profile_skills", side_effect=fake_seed),
202+
patch("tools.skills_sync.sync_skills", return_value=empty_sync),
203+
):
204+
cmd_update(mock_args)
205+
206+
assert active_p.path in synced_paths, (
207+
f"Active profile 'bit' must be included in skill sync; got: {synced_paths}"
208+
)
209+
assert set(synced_paths) == {p.path for p in all_profiles}, (
210+
f"All profiles must be synced; got: {synced_paths}"
211+
)
212+
213+
@patch("shutil.which", return_value=None)
214+
@patch("subprocess.run")
215+
def test_single_profile_default_is_synced(
216+
self, mock_run, _mock_which, mock_args, capsys
217+
):
218+
from pathlib import Path
219+
220+
mock_run.side_effect = _make_run_side_effect(
221+
branch="main", verify_ok=True, commit_count="1"
222+
)
223+
224+
default_p = SimpleNamespace(name="default", path=Path("/fake/.hermes"))
225+
synced_paths = []
226+
227+
def fake_seed(path, quiet=False):
228+
synced_paths.append(path)
229+
return {"copied": [], "updated": [], "user_modified": []}
230+
231+
empty_sync = {"copied": [], "updated": [], "user_modified": [], "cleaned": []}
232+
233+
with (
234+
patch("hermes_cli.profiles.list_profiles", return_value=[default_p]),
235+
patch("hermes_cli.profiles.seed_profile_skills", side_effect=fake_seed),
236+
patch("tools.skills_sync.sync_skills", return_value=empty_sync),
237+
):
238+
cmd_update(mock_args)
239+
240+
assert default_p.path in synced_paths

0 commit comments

Comments
 (0)