From 51f9953e69d303c3d278e41295b1a5c786bf8d87 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 7 May 2026 04:34:38 -0700 Subject: [PATCH] feat(profiles): --no-skills flag for empty profile creation (#20986) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `hermes profile create --no-skills` to create a profile with zero bundled skills. Writes a `.no-bundled-skills` marker file in the profile root so `hermes update`'s all-profile skill sync loop also skips the profile — without the marker, every update would re-seed skills and the user would have to delete them again. Use case (from @hiut1u): orchestrator profiles and narrow-task profiles don't need 100+ bundled skills polluting their system prompt. - create_profile() gains a `no_skills` param, mutually exclusive with `--clone` / `--clone-all` (cloning explicitly copies skills). - seed_profile_skills() no-ops on opted-out profiles and returns `{skipped_opt_out: True}` so callers can report cleanly. - Web API (POST /api/profiles) accepts `no_skills: bool`. - Delete `.no-bundled-skills` to opt back in — next `hermes update` re-seeds normally. 6 new tests in TestNoSkillsOptOut cover marker write, mutual exclusion with clone, seed_profile_skills opt-out, fresh profile unaffected, and delete-marker-re-enables-seeding. --- hermes_cli/main.py | 22 +++++- hermes_cli/profiles.py | 52 ++++++++++++++ hermes_cli/web_server.py | 5 +- tests/hermes_cli/test_profiles.py | 113 ++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 4 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 26d957f819..4451704b1b 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7331,7 +7331,9 @@ def _cmd_update_impl(args, gateway_mode: bool): for p in all_profiles: try: r = seed_profile_skills(p.path, quiet=True) - if r: + if r and r.get("skipped_opt_out"): + status = "opted out (--no-skills)" + elif r: copied = len(r.get("copied", [])) updated = len(r.get("updated", [])) modified = len(r.get("user_modified", [])) @@ -8124,6 +8126,7 @@ def cmd_profile(args): clone = getattr(args, "clone", False) clone_all = getattr(args, "clone_all", False) no_alias = getattr(args, "no_alias", False) + no_skills = getattr(args, "no_skills", False) try: clone_from = getattr(args, "clone_from", None) @@ -8134,6 +8137,7 @@ def cmd_profile(args): clone_all=clone_all, clone_config=clone, no_alias=no_alias, + no_skills=no_skills, ) print(f"\nProfile '{name}' created at {profile_dir}") @@ -8158,10 +8162,17 @@ def cmd_profile(args): except Exception: pass # Honcho plugin not installed or not configured - # Seed bundled skills (skip if --clone-all already copied them) + # Seed bundled skills (skip if --clone-all already copied them, or + # if --no-skills was passed — in which case seed_profile_skills() + # honors the marker file and returns skipped_opt_out=True). if not clone_all: result = seed_profile_skills(profile_dir) - if result: + if result and result.get("skipped_opt_out"): + print( + "No bundled skills seeded (--no-skills). " + "Delete .no-bundled-skills in the profile to opt back in." + ) + elif result: copied = len(result.get("copied", [])) print(f"{copied} bundled skills synced.") else: @@ -10523,6 +10534,11 @@ Examples: profile_create.add_argument( "--no-alias", action="store_true", help="Skip wrapper script creation" ) + profile_create.add_argument( + "--no-skills", + action="store_true", + help="Create an empty profile with no bundled skills (opts out of `hermes update` skill sync)", + ) profile_delete = profile_subparsers.add_parser("delete", help="Delete a profile") profile_delete.add_argument("profile_name", help="Profile to delete") diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 10cd36b88c..93928364c4 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -71,6 +71,22 @@ _CLONE_ALL_STRIP = [ "processes.json", ] +# Marker file written by `hermes profile create --no-skills`. When present in +# a profile's root, callers of seed_profile_skills() (fresh-create, `hermes +# update`'s all-profile sync, the web dashboard) skip bundled-skill seeding +# for that profile. The user can still install skills manually via +# `hermes skills install` or drop SKILL.md files into the profile's skills/. +# Delete the marker file to opt back in. +NO_BUNDLED_SKILLS_MARKER = ".no-bundled-skills" + + +def has_bundled_skills_opt_out(profile_dir: Path) -> bool: + """Return True if the profile opted out of bundled-skill seeding.""" + try: + return (profile_dir / NO_BUNDLED_SKILLS_MARKER).exists() + except OSError: + return False + def _clone_all_copytree_ignore(source_dir: Path): """Ignore ``profiles/`` at the root of *source_dir* only. @@ -427,6 +443,7 @@ def create_profile( clone_all: bool = False, clone_config: bool = False, no_alias: bool = False, + no_skills: bool = False, ) -> Path: """Create a new profile directory. @@ -444,12 +461,22 @@ def create_profile( skills, and selected profile identity files from the source profile. no_alias: If True, skip wrapper script creation. + no_skills: + If True, create an empty profile with no bundled skills, and write + a marker file so ``hermes update`` skips re-seeding this profile's + skills. Mutually exclusive with ``clone_config``/``clone_all`` (those + explicitly copy skills from the source). Returns ------- Path The newly created profile directory. """ + if no_skills and (clone_config or clone_all): + raise ValueError( + "--no-skills is mutually exclusive with --clone / --clone-all " + "(cloning explicitly copies skills from the source profile)." + ) canon = normalize_profile_name(name) validate_profile_name(canon) @@ -527,6 +554,19 @@ def create_profile( except Exception: pass # best-effort — don't fail profile creation over this + # Write the opt-out marker so seed_profile_skills() and `hermes update`'s + # all-profile sync loop both skip this profile for bundled-skill seeding. + if no_skills: + try: + (profile_dir / NO_BUNDLED_SKILLS_MARKER).write_text( + "This profile opted out of bundled-skill seeding " + "(`hermes profile create --no-skills`).\n" + "Delete this file to re-enable sync on the next `hermes update`.\n", + encoding="utf-8", + ) + except OSError: + pass # best-effort — the feature still works via the empty skills/ dir + return profile_dir @@ -535,7 +575,19 @@ def seed_profile_skills(profile_dir: Path, quiet: bool = False) -> Optional[dict Uses subprocess because sync_skills() caches HERMES_HOME at module level. Returns the sync result dict, or None on failure. + + Profiles that opted out of bundled skills (via ``hermes profile create + --no-skills`` — which writes ``.no-bundled-skills`` to the profile root) + are skipped and get an empty-result dict so callers can report + "opted out" instead of "failed". """ + if has_bundled_skills_opt_out(profile_dir): + return { + "copied": [], + "updated": [], + "user_modified": [], + "skipped_opt_out": True, + } project_root = Path(__file__).parent.parent.resolve() try: result = subprocess.run( diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 754dd83443..5469cff607 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -2366,6 +2366,7 @@ async def delete_cron_job(job_id: str): class ProfileCreate(BaseModel): name: str clone_from_default: bool = False + no_skills: bool = False class ProfileRename(BaseModel): @@ -2471,11 +2472,13 @@ async def create_profile_endpoint(body: ProfileCreate): name=body.name, clone_from="default" if body.clone_from_default else None, clone_config=body.clone_from_default, + no_skills=body.no_skills, ) # Match the CLI's profile-create flow: fresh named profiles get the # bundled skills installed. When cloning from default, create_profile() # has already copied the source profile's skills, including any - # user-installed skills. + # user-installed skills. When no_skills=True, create_profile() wrote + # the opt-out marker and seed_profile_skills() will no-op. if not body.clone_from_default: profiles_mod.seed_profile_skills(path, quiet=True) diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index 7ddb8fd20a..130b1c39e4 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -33,6 +33,9 @@ from hermes_cli.profiles import ( generate_zsh_completion, _get_profiles_root, _get_default_hermes_home, + seed_profile_skills, + has_bundled_skills_opt_out, + NO_BUNDLED_SKILLS_MARKER, ) @@ -243,6 +246,116 @@ class TestCreateProfile: assert (profile_dir / "SOUL.md").exists() +# =================================================================== +# TestNoSkillsOptOut +# =================================================================== + +class TestNoSkillsOptOut: + """Tests for `hermes profile create --no-skills` and the opt-out marker.""" + + def test_no_skills_writes_marker_and_skips_seeding(self, profile_env): + profile_dir = create_profile("orchestrator", no_alias=True, no_skills=True) + + # Marker file is present + marker = profile_dir / NO_BUNDLED_SKILLS_MARKER + assert marker.is_file(), "expected .no-bundled-skills marker in profile root" + assert "--no-skills" in marker.read_text() + + # has_bundled_skills_opt_out() agrees + assert has_bundled_skills_opt_out(profile_dir) is True + + # skills/ dir exists (profile bootstrapping still creates the dir) but + # contains nothing yet because create_profile itself doesn't seed. + assert (profile_dir / "skills").is_dir() + assert list((profile_dir / "skills").iterdir()) == [] + + def test_no_skills_conflicts_with_clone(self, profile_env): + with pytest.raises(ValueError, match="mutually exclusive"): + create_profile( + "orchestrator", + no_alias=True, + no_skills=True, + clone_config=True, + ) + + def test_no_skills_conflicts_with_clone_all(self, profile_env): + with pytest.raises(ValueError, match="mutually exclusive"): + create_profile( + "orchestrator", + no_alias=True, + no_skills=True, + clone_all=True, + ) + + def test_seed_profile_skills_respects_marker(self, profile_env): + """seed_profile_skills() must no-op on opted-out profiles even when + called directly (e.g. by `hermes update`'s all-profile sync loop).""" + profile_dir = create_profile("orchestrator", no_alias=True, no_skills=True) + + # Call seed_profile_skills() directly — it should NOT invoke subprocess, + # NOT modify the skills/ dir, and return a dict with skipped_opt_out=True. + result = seed_profile_skills(profile_dir, quiet=True) + + assert result is not None + assert result.get("skipped_opt_out") is True + assert result.get("copied") == [] + # skills/ stays empty — no subprocess ran + assert list((profile_dir / "skills").iterdir()) == [] + + def test_default_profile_gets_skills_seeded(self, profile_env, monkeypatch): + """Sanity: without --no-skills, seed_profile_skills() runs the real + subprocess path. Mock the subprocess so the test is hermetic, and + just confirm the marker is NOT checked in the non-opt-out case.""" + import subprocess as _sp + + profile_dir = create_profile("coder", no_alias=True) + # No marker — not opted out + assert not (profile_dir / NO_BUNDLED_SKILLS_MARKER).exists() + assert has_bundled_skills_opt_out(profile_dir) is False + + # Mock subprocess.run to avoid actually running skill sync in tests + calls = [] + + def fake_run(*args, **kwargs): + calls.append(args) + return _sp.CompletedProcess( + args=args, returncode=0, stdout='{"copied": ["x"]}', stderr="" + ) + + monkeypatch.setattr("subprocess.run", fake_run) + result = seed_profile_skills(profile_dir, quiet=True) + + # Subprocess was invoked (the opt-out branch did NOT short-circuit) + assert len(calls) == 1 + assert result == {"copied": ["x"]} + + def test_delete_marker_re_enables_seeding(self, profile_env, monkeypatch): + """Deleting .no-bundled-skills opts the profile back in.""" + import subprocess as _sp + + profile_dir = create_profile("orchestrator", no_alias=True, no_skills=True) + assert has_bundled_skills_opt_out(profile_dir) is True + + # First call: opted out, returns skipped dict without touching subprocess + called = [] + monkeypatch.setattr( + "subprocess.run", + lambda *a, **kw: (called.append(a), _sp.CompletedProcess( + args=a, returncode=0, stdout='{"copied": []}', stderr="" + ))[1], + ) + r1 = seed_profile_skills(profile_dir, quiet=True) + assert r1.get("skipped_opt_out") is True + assert called == [] + + # Delete marker → next call runs the real path + (profile_dir / NO_BUNDLED_SKILLS_MARKER).unlink() + assert has_bundled_skills_opt_out(profile_dir) is False + r2 = seed_profile_skills(profile_dir, quiet=True) + assert r2 == {"copied": []} + assert len(called) == 1 + + # =================================================================== # TestDeleteProfile # ===================================================================