mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-20 10:11:58 +00:00
feat(skills): blank-slate skills — install --no-skills + opt-out/opt-in (#36228)
* feat(install): --no-skills flag for blank-slate default profile Add an install-time --no-skills flag so the default ~/.hermes profile can be created with zero bundled skills, matching what `hermes profile create --no-skills` already does for named profiles. The flag writes $HERMES_HOME/.no-bundled-skills and skips the install-time seed. sync_skills() now honors that marker with an early return (skipped_opt_out=True), so neither the installer, a later `hermes update`, nor a direct sync re-injects bundled skills into a profile that opted out. Previously the marker was only checked by seed_profile_skills() (named profiles); the default profile had no opt-out and `hermes update` would re-seed it every time. Tests: TestNoBundledSkillsOptOut covers marker-present (no-op) and marker-absent (normal seed) paths. * feat(skills): hermes skills opt-out / opt-in for existing profiles Adds an interactive counterpart to the install-time --no-skills flag so an already-installed profile (default or named) can toggle the .no-bundled-skills marker without reinstalling. - `hermes skills opt-out` writes the marker (stop future seeding). Safe by default: nothing on disk is touched. - `hermes skills opt-out --remove` ALSO deletes already-present bundled skills, but ONLY ones that are manifest-tracked AND byte-identical to their origin hash. User-edited bundled skills, hub-installed skills, and hand-written skills are never removed. Previews + confirms before deleting (--yes to skip). - `hermes skills opt-in [--sync]` removes the marker and optionally re-seeds immediately. Core logic lives in tools/skills_sync.py (set_bundled_skills_opt_out, is_bundled_skills_opt_out, remove_pristine_bundled_skills) reusing the existing manifest origin-hash machinery for the safety check. Tests: TestOptOutToggleAndRemove covers marker toggle idempotency and proves user-modified + non-bundled skills survive --remove. * docs: blank-slate skills — install --no-skills + opt-out/opt-in - features/skills.md: new 'Starting with a blank slate' section covering the install flag, profile-create flag, and runtime opt-out/opt-in, with a safe-by-default note. - reference/cli-commands.md: document the new skills opt-out / opt-in subcommands + examples. - reference/profile-commands.md: fix the marker filename (was .no-skills, actually .no-bundled-skills) and cross-link the runtime commands. Validated with a full docusaurus build (exit 0); the three edited pages compile clean with no new warnings.
This commit is contained in:
parent
70e1571d89
commit
2ed96372ad
8 changed files with 475 additions and 9 deletions
|
|
@ -947,3 +947,123 @@ class TestResetBundledSkill:
|
|||
assert "google-workspace" in manifest_after
|
||||
# User copy is still on disk (we changed nothing).
|
||||
assert (dest / "SKILL.md").exists()
|
||||
|
||||
|
||||
class TestNoBundledSkillsOptOut:
|
||||
"""The .no-bundled-skills marker makes sync_skills() a no-op.
|
||||
|
||||
This is what `hermes profile create --no-skills` (named profiles) and the
|
||||
installer's `--no-skills` flag (default ~/.hermes) rely on so bundled
|
||||
skills are never seeded at install time NOR re-injected by `hermes update`.
|
||||
"""
|
||||
|
||||
def _setup_bundled(self, tmp_path):
|
||||
bundled = tmp_path / "bundled"
|
||||
skill = bundled / "category" / "new-skill"
|
||||
skill.mkdir(parents=True)
|
||||
(skill / "SKILL.md").write_text("---\nname: new-skill\n---\nbody\n")
|
||||
return bundled
|
||||
|
||||
def test_marker_skips_sync(self, tmp_path):
|
||||
bundled = self._setup_bundled(tmp_path)
|
||||
skills_dir = tmp_path / "user_skills"
|
||||
manifest_file = skills_dir / ".bundled_manifest"
|
||||
hermes_home = tmp_path / "home"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / ".no-bundled-skills").write_text("opted out\n")
|
||||
|
||||
with patch("tools.skills_sync._get_bundled_dir", return_value=bundled), \
|
||||
patch("tools.skills_sync.SKILLS_DIR", skills_dir), \
|
||||
patch("tools.skills_sync.MANIFEST_FILE", manifest_file), \
|
||||
patch("tools.skills_sync.HERMES_HOME", hermes_home):
|
||||
result = sync_skills(quiet=True)
|
||||
|
||||
# Opt-out signalled, nothing copied, nothing written to disk.
|
||||
assert result["skipped_opt_out"] is True
|
||||
assert result["copied"] == []
|
||||
assert result["total_bundled"] == 0
|
||||
assert not (skills_dir / "category" / "new-skill" / "SKILL.md").exists()
|
||||
|
||||
def test_no_marker_seeds_normally(self, tmp_path):
|
||||
bundled = self._setup_bundled(tmp_path)
|
||||
skills_dir = tmp_path / "user_skills"
|
||||
manifest_file = skills_dir / ".bundled_manifest"
|
||||
hermes_home = tmp_path / "home"
|
||||
hermes_home.mkdir()
|
||||
# No marker written.
|
||||
|
||||
with patch("tools.skills_sync._get_bundled_dir", return_value=bundled), \
|
||||
patch("tools.skills_sync._get_optional_dir", return_value=bundled.parent / "optional-skills"), \
|
||||
patch("tools.skills_sync.SKILLS_DIR", skills_dir), \
|
||||
patch("tools.skills_sync.MANIFEST_FILE", manifest_file), \
|
||||
patch("tools.skills_sync.HERMES_HOME", hermes_home):
|
||||
result = sync_skills(quiet=True)
|
||||
|
||||
assert result.get("skipped_opt_out") is not True
|
||||
assert "new-skill" in result["copied"]
|
||||
assert (skills_dir / "category" / "new-skill" / "SKILL.md").exists()
|
||||
|
||||
|
||||
class TestOptOutToggleAndRemove:
|
||||
"""`hermes skills opt-out/opt-in` core: marker toggle + safe removal."""
|
||||
|
||||
def _setup_bundled(self, tmp_path):
|
||||
bundled = tmp_path / "bundled"
|
||||
for n in ("alpha", "beta"):
|
||||
d = bundled / n
|
||||
d.mkdir(parents=True)
|
||||
(d / "SKILL.md").write_text(f"---\nname: {n}\n---\nbody {n}\n")
|
||||
return bundled
|
||||
|
||||
def test_marker_toggle(self, tmp_path):
|
||||
from tools.skills_sync import (
|
||||
set_bundled_skills_opt_out, is_bundled_skills_opt_out,
|
||||
)
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
with patch("tools.skills_sync.HERMES_HOME", home):
|
||||
assert is_bundled_skills_opt_out() is False
|
||||
r = set_bundled_skills_opt_out(True)
|
||||
assert r["ok"] and r["changed"]
|
||||
assert is_bundled_skills_opt_out() is True
|
||||
# idempotent
|
||||
r2 = set_bundled_skills_opt_out(True)
|
||||
assert r2["ok"] and r2["changed"] is False
|
||||
# opt back in
|
||||
r3 = set_bundled_skills_opt_out(False)
|
||||
assert r3["ok"] and r3["changed"]
|
||||
assert is_bundled_skills_opt_out() is False
|
||||
|
||||
def test_remove_keeps_user_modified(self, tmp_path):
|
||||
from tools.skills_sync import (
|
||||
sync_skills, remove_pristine_bundled_skills,
|
||||
)
|
||||
bundled = self._setup_bundled(tmp_path)
|
||||
skills_dir = tmp_path / "user_skills"
|
||||
manifest_file = skills_dir / ".bundled_manifest"
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
with patch("tools.skills_sync._get_bundled_dir", return_value=bundled), \
|
||||
patch("tools.skills_sync._get_optional_dir", return_value=bundled.parent / "optional-skills"), \
|
||||
patch("tools.skills_sync.SKILLS_DIR", skills_dir), \
|
||||
patch("tools.skills_sync.MANIFEST_FILE", manifest_file), \
|
||||
patch("tools.skills_sync.HERMES_HOME", home):
|
||||
sync_skills(quiet=True)
|
||||
# User edits 'beta'
|
||||
(skills_dir / "beta" / "SKILL.md").write_text("---\nname: beta\n---\nEDITED\n")
|
||||
# A hand-written, non-bundled skill must also survive.
|
||||
(skills_dir / "mine").mkdir()
|
||||
(skills_dir / "mine" / "SKILL.md").write_text("---\nname: mine\n---\nlocal\n")
|
||||
|
||||
preview = remove_pristine_bundled_skills(dry_run=True)
|
||||
assert "alpha" in preview["removed"]
|
||||
assert "beta" not in preview["removed"]
|
||||
|
||||
result = remove_pristine_bundled_skills(dry_run=False)
|
||||
assert "alpha" in result["removed"]
|
||||
assert not (skills_dir / "alpha").exists()
|
||||
# user-modified bundled skill kept
|
||||
assert (skills_dir / "beta" / "SKILL.md").exists()
|
||||
assert "EDITED" in (skills_dir / "beta" / "SKILL.md").read_text()
|
||||
# non-bundled local skill never considered
|
||||
assert (skills_dir / "mine" / "SKILL.md").exists()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue